Хороший код читается легко, как проза. Многие книги учат нас тому, как важно делить код на небольшие, повторно используемые, легко потребляемые блоки.

Но почему-то, в случае с регэкспами у программистов как будто появляется слепое пятно на чувстве стиля. Вот такая регулярка – совершенно обычное дело:

/^(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})$/
(?<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 за то, что они начали отличную ветку о гибридном подходе в соседнем посте.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Я видел в коде (или писал сам) декомпозированные регулярки
50.75%никогда не видел68
28.36%видел пару раз38
3.73%часто видел5
8.21%постоянно декомпозирую и других учу11
8.96%НЛО разложит меня на составные блоки, если я расскажу12
Проголосовали 134 пользователя. Воздержались 25 пользователей.