Хороший код читается легко, как проза. Многие книги учат нас тому, как важно делить код на небольшие, повторно используемые, легко потребляемые блоки.
Но почему-то, в случае с регэкспами у программистов как будто появляется слепое пятно на чувстве стиля. Вот такая регулярка – совершенно обычное дело:
/^(0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])[- /.]((19|20)\d\d)$/
Долго вглядываясь в нее, мы, наверное, поймем рано или поздно, что она содержит четыре логических блока - три чиселка, по-разному ограниченных, и некоторый разделитель [- /.]
Не лучше ли ее записать вот так?
"/^" + month + delimiter + day + delimiter + year + "$/"
Матерь божья, да это же дата! Сколько наносекунд вам потребовалось для того, чтобы это понять?
Почему же за однострочник вроде тех, что пишет легендарный Stefan Pochmann c leetcode, тебе сразу оторвут руки, а на художества с регулярками смотрят сквозь пальцы? Мне не слишком понятно почему.
// отвратительный однострочник Стефана Почманна
TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
return root1&&root2 ? new TreeNode(root1->val+root2->val,mergeTrees(root1->left,root2->left),mergeTrees(root1->right,root2->right)):root1?root1:root2;
}
Наверное, это феномен того же порядка, как и то, что творится в большинстве файлов с юнит-тестами. Дублирование кода, отключенные линтеры, длинные слабочитаемые выражения и халатность при код-ревью.
Итак: не склеивайте регулярки в одну большую регулярку, как будто вы – первокурсник, понтующийся своим однострочником. Регулярки – это код должно быть легко читать, отлаживать и модифицировать. Чтобы он стал таковым, надо большое и непонятное содержимое разбить на группы маленького и понятного. В педагогике это называется chucking, у нас – decomposition.
Действовать будем исходя из следующих положений:
В большинстве языков регулярное выражение может создаваться на основе строки.
В большинстве сложных регулярок можно выделить составные части.
Строки можно конкатенировать.
Подход нехитрый: выделяем смысловые кусочки в переменные и функции, потом все склеиваем. Для примера возьмем регулярочку из другой хабростатьи:
// 1. Строка начинается только с заглавной латинской буквы или цифры
// 2. За ним может быть разрешенный спецсимвол или единичный пробел
// 3. Нельзя использовать кириллицу и другие спецсимволы
const someEngPattern = /^[A-Z0-9]+([a-zA-Z0-9\\!\\#\\%]|\\s(?!\\s))*$/;
Это не очень хороший код, на его чтение у меня ушло много времени.
Кроме того, этот код предваряется длинным комментарием, описывающий, что происходит в регулярке. Каким бы ни был болтуном и сектантом автор книги Clean Code Дядюшка Боб (Robert Martin), с его мнением о комментариях в коде я согласен. Комментарии врут. Если они не врут прямо сейчас, то они будут врать в будущем, когда кто-то внесет изменения в код и забудет обновить комментарий. Альтернатива комментариям - это промежуточные переменные и функции с говорящими именами.
Я буду декомпозировать нашу регулярочку "снаружи вовнутрь", шаг за шагом, а потом посмотрим, что получилось. Примеры будут на JS/TS, но для других языков все будет так же.
Шаг 1: начало и конец ввода
Например, давайте сразу избавимся от пары /^
и $/
function wholeInput(regex) {
return "/^" + regex + "$/";
}
const someEngPattern = wholeInput("[A-Z0-9]+([a-zA-Z0-9!#%]|\\s(?!\\s))*")
Шаг 2: выделим крупные логические блоки
Два блока, которые вижу я - это первая группа буковок (начинается с большой буквы или с цифры) и все остальное. По лингвистической традиции обзовем их префиксом и суффиксом:
const prefix = "[A-Z0-9]+"
const suffix = "([a-zA-Z0-9\\!\\#\\%]|\\s(?!\\s))*"
const someEngPattern=wholeInput(prefix + suffix)
На мой взгляд, префикс уже достаточно прост для того, чтобы дальше его не декомпозировать. А вот в суффиксе происходит довольно много всего.
Шаг 3. опять выделяем логические блоки
Простенькая функция для скобочек и звездочки (а скобочки обозначают группу, то есть группа может повторяться от нуля до бесконечности раз):
function group(regex) {
return "(" + regex + ")";
}
const suffix = group("[a-zA-Z0-9\\!\\#\\%]|\\s(?!\\s)") + "*"
Шаг 4. Суффикс и предел декомпозиции
В суффиксе в конце у нас есть что-то хитрое с пробелами:
const onlyOneWhiteSpace="\\s(?!\\s)";
Можно ли упростить его еще дальше? Мне сложновато будет найти хорошие имена переменных, поэтому желающему понять, как работает эта конструкция все же придется погрузиться в справочник.
Шаг 5. Экранирование
В кусочке [a-zA-Z0-9\\!\\#\\%]
префиксе у нас налицо куча экранированных симовлов. У меня от этих бесконечных палок рябит в глазах, поэтому сделаю-ка я функцию escape
:
function escape(rawChar) {
return "\\" + rawChar;
}
Для куска a-zA-Z0-9
можно придумать имя:
const letterOrNumber = "a-zA-Z0-9";
Шаг 6. Переменные или функции для управляющих конструкций
Одна из сложностей в регекспах - это понять, где у нас управляющие конструкции, а где символы, которые мы match'им. Создадим функцию charClass
, которая будет обрамлять свое содержимое квадратными скобочками.
function charClass(regex) {
return "[" + regex + "]";
}
А еще я создал переменную or
– специально чтобы никто не подумал, что это просто match символа вертикальной палки.
const or = "|"
const suffixLetter = charClass(letterOrNumber+specialChar) + or + onlyOneWhiteSpace;
Результат декомпозиции
Было: большая регулярка
// 1. Строка начинается только с заглавной латинской буквы или цифры
// 2. За ним может быть разрешенный спецсимвол или единичный пробел
// 3. Нельзя использовать кириллицу и другие спецсимволы
const someEngPattern = /^[A-Z0-9]+([a-zA-Z0-9\\!\\#\\%]|\\s(?!\\s))*$/;
Стало: куча функций и переменных, скомбинированных друг с другом
function escape(rawChar) {
return "\\" + rawChar;
}
function charClass(regex) {
return "[" + regex + "]";
}
function wholeInput(regex) {
return "/^" + regex + "$/";
}
function group(regex) {
return "(" + regex + ")";
}
const prefix = "[A-Z0-9]+";
const letterOrNumber = "a-zA-Z0-9";
const specialChar = escape("!") + escape("#") + escape("%");
const onlyOneWhiteSpace="\\s(?!\\s)";
const or = "|"
const suffixLetter = charClass(letterOrNumber+specialChar) + or + onlyOneWhiteSpace;
const suffix = group(suffixLetter) + "*";
const someEngPattern=wholeInput(prefix + suffix)
Возможно, еще стоило бы переименовать переменные префикс
и суффикс
во что-то удобоваримое, но уж очень громоздкими получаются имена переменных: whitespaceSeparatedLetterNumericalWithSpecChars
, брр.
Тут налицо ограничения нашего декомпозиционного подхода. В отличие от семантически ясных day
, month
и year
из предыдущего примера, крупным и сложным сущностям без ясной семантики сложновато подобрать звучные и короткие названия. Как следствие, их природу приходится порой скрывать за безликими лингвистическими жаргонизмами вроде prefix
и suffix
.
Анализ
Стоит ли овчинка выделки? Этот код я писал дольше, чем записал бы гига-регэксп выше, зато:
его легче читать;
для его понимания почти не надо лезть в справочник;
его куски можно повторно использовать. Функции
group
,escape
иwholeInput
понадобятся и потом;его куски можно напрямую отлаживать;
про производительность - не смешите меня, все заинлайнится как миленькое даже в хилом V8, не говоря о дюжем gcc;
если ты - гигант и умеешь читать гига-регэкспы разом, то ты просто можешь добавить
console.log(someEngPattern)
.
Принципы и лучшие практики
Степень декомпозиции зависит от вас и вашей команды, а остановиться можно в любой момент. Код, который у меня получился, может быть в два раза короче, например вот таким:
function wholeInput(regex) {
return "/^" + regex + "$/";
}
function zeroOrMore(regex) {
return "(" + regex + ")*";
}
const or = "|"
const onlyOneWhiteSpace="\\s(?!\\s)";
const suffix = zeroOrMore ("[a-zA-Z0-9\\!\\#\\%]" + or + onlyOneWhiteSpace)
const someEngPattern = wholeInput( "[A-Z0-9]+" + suffix)
Правда ж он менее мерзкий?
Я сейчас собираю принципы для работы с регулярками, вот кое-что:
выносим отдельно надо то, от чего рябит в глазах. Кучи скобочек, обилие слэшей, все, в чем можно запутаться;
выносим то, что представляет собой понятный логический блок. Примеры выше -
день
,месяц
,год
,не более одного пробела
;выносим то, для чего требуется редко используемый синтаксис.
a-zA-Z
знают многие, а вот в\\s(?!\\s)
сразу и не въедешь;выносим управляющие символы, которые легко перепутать с искомыми символами;
группируем так, чтобы было понятно, к чему относится тот или иной управляющий символ;
если позволяет язык и есть потребность - используем multiline-строки и режим игнорирования whitespace'ов, тогда можно форматировать их с отступами, прям как в нормальном коде, гляньте на пример, предоставленный @shoorick. Для наших примеров использовать обратнокавычечные строки из JS смысла не было, да и переменные внутри них выглядят довольно неуклюже.
Другие подходы
За 15 лет в индустрии я лишь один раз видел, чтобы разработчики декомпозировали свои регулярки. Остальные хреначат в одну строчку и полагаются на авось.
Ну конечно, это плохой способ подводить статистику, и на просторах интернета я нашел несколько других интересных направлений:
Библиотека mol-regex – это когда подход, взятый в нашей статье, доведен до логического завершения. Если у вас действительно много регулярок и api библиотеки вам по душе – надо брать! Коллега @ninjin описывает его в своей статье:
// /4(?:\d){12,}?(?:(?:\d){3,}?){0,1}/gsu
const VISA = from([
'4',
repeat( decimal_only, 12 ),
[ repeat( decimal_only, 3 ) ],
])
Вербальные выражения заслуживают отдельной статьи и позволяют получать вот такое:
const tester = VerEx()
.startOfLine()
.then('http')
.maybe('s')
.then('://')
.maybe('www.')
.anythingBut(' ')
.endOfLine();
Библиотека SuperExpressive - еще один билдер регулярок, который вспомнил @FanatPHP. Обратите внимание на функцию
end()
и табуляцию:
const SuperExpressive = require('super-expressive');
const myRegex = SuperExpressive()
.startOfInput
.optional.string('0x')
.capture
.exactly(4).anyOf
.range('A', 'F')
.range('a', 'f')
.range('0', '9')
.end()
.end()
.endOfInput
.toRegex();
// Produces the following regular expression:
/^(?:0x)?([A-Fa-f0-9]{4})$/
Именованные группы - довольно приятная функциональность, встроенная в регулярные выражения, поддерживаемая в большинстве современных языков и улучшающая читабельность. Спасибо @ubx7b8 за то, что вспомнил. Отличный метод работы в случаях, когда у группы есть понятная семантика, например в примере с датой. В микрософтовском руководстве приводится вот такой пример:
(?<duplicateWord>\w+)\s\k<duplicateWord>\W(?<nextWord>\w+)
Наконец для сложных штук, типа написания своих раскрашивателей кода или анализаторов DSL, можно уже и грамматиками воспользоваться, а они нам парсеров нагенерируют как делают в PEG.js. Объемная статья про парсинг (в js).
Буду рад, если кто-нибудь принесет примеров из респектабельных open-source проектов, и мы вместе вместе покумекаем над принципами и границами применимости.
Не забывайте о гибридном подходе
И еще один принцип, о котором часто забывают: каждому инструменту – свое применение. Молотки, гвозди, ну вы поняли.
Даже если вы декомпозируете пример с датой из начала статьи, конструкция (0[1-9]|[12][0-9]|3[01])
- это плохой и невкусно пахнущий код.
Почему? Да потому, что он использует текстовые методы для анализа чиселки. Выковыряйте чиселку года вульгарным \d{1,4}
, приведите в тип числа и верифицируйте уже численными методами:
function isValidYear(year: number): boolean {
if (isNaN(year)) {
return false;
}
return year > 0 && year < 3000; // ну уж тысячу лет мой код точно проживет
}
Другой понятный случай применения гибридного подхода: это когда часть выражения проще/читабельнее верифицировать через старые добрые циклы и строковые операции, чем через регэкспы.
Спасибо хабраюзерам @DirectoriX и @0x131315 за то, что они начали отличную ветку о гибридном подходе в соседнем посте.