[Перевод] Как в PHP улучшить читаемость регулярных выражений
Регулярные выражения — очень мощный инструмент, однако согласно общепринятому мнению, после того как они написаны, их очень трудно понять, поэтому их поддержка — не самое приятное занятие. Здесь собраны советы, которые помогут сделать из более читаемыми.
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);
Уменьшение количества экранированных символов
Кроме выбора подходящего символа-ограничителя есть и другие способы уменьшить количество экранирований в регулярном выражении. Некоторые метасимволы языка регулярных выражений не являются таковыми при использовании в задаваемом диапазоне символов в квадратных скобках. Например, это символы .
, *
, +
, $
. Поэтому, в выражении /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: (?£|€)(?\d+)/
Как можно видеть, именованная захватывающая группа открывается символами (?
, за которыми в угловых скобках следует имя группы, и закрывается все той же круглой скобкой. В примере выше, (?
— именованная группа currency, а (?
— группа под названием price. Такое написание с одной стороны позволяет читающему код регулярного выражения программисту понять, что делает каждая именованная группа, с другой — понятным образом именует элементы массива с результатами работы выражения. Например, выполнив код:
$pattern = '/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
:
/Price: (?£|€)?(?\d+)(?£|€)?/J
Здесь мы видим 2 захватывающие группы с одинаковым названием currency, и такое регулярное выражение не вызовет ошибки, потому что в нем использован модификатор J
. В результате работы такого выражения, в именованном элементе массива currency будет возвращена подстрока, соответствующая самой последней группе с этим названием. При этом, в индексированных элементах массива будут подстроки, соответствующие каждой группе:
$pattern = '/Price: (?£|€)?(?\d+)(?£|€)?/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: (?£|€)(?\d+)/i';
Можно написать:
$pattern = '/Price: ';
$pattern .= '(?£|€)'; // Capture currency symbols £ or €
$pattern .= '(?\d+)'; // Capture price without decimals.
$pattern .= '/i'; // Flags: Case-insensitive
Комментарии можно добавлять и в сами регулярные выражения. Существует флаг x
, с которым компилятор регулярных выражений начинает игнорировать в выражении все пробельные символы, в том числе и переносы строк. Это позволяет визуально разграничить части выражения и даже разбить его на несколько строк, не меняя его смысла. Сравните:
/Price: (?£|€)(?\d+)/i
и
/Price: \s (?£|€) (?\d+) /ix
Во втором случае, пробелы позволяет повысить читаемость выражения, при этом компилятор регулярных выражений их игнорирует за счет использования флага x
. Пробелами можно выделить логические последовательности подшаблонов регулярного выражения, сделав, таким образом, его более понятным. Если же нужно, чтобы выражение искало пробелы, то нужно применить в нем специальную последовательность символов \s
.
Кроме игнорирования пробелов флаг x
также включает возможность добавления в тело регулярного выражения комментариев, начинающихся с символа #
, и по синтаксису похожих на комментарии в PHP коде. Выражение можно сделать еще более читаемым, разбив его на несколько строк и добавив к ним комментарии. Например, вместо:
/Price: (?£|€)(?\d+)/i
Можно написать:
/Price: # Check for the label "Price:"
\s # Ensure a white-space after.
(?£|€) # Capture currency symbols £ or €
(?\d+) # Capture price without decimals.
/ix
Для записи отформатированного в таком виде выражения в виде строки PHP, лучше всего использовать Heredoc или Nowdoc синтаксис. Таким образом, получим:
$pattern = <<£|€) # Capture currency symbols £ or €
(?\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-zA-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);
мы видим, что даже, имея дело с незнакомым и экзотическим языком, мы можем с пользой применить регулярные выражения!