Декомпозируем регулярные выражения

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

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

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

/^(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.

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

Действовать будем исходя из следующих положений:

  • В большинстве языков регулярное выражение может создаваться на основе строки.

  • В большинстве сложных регулярок можно выделить составные части.

  • Строки можно конкатенировать.

Подход нехитрый: выделяем смысловые кусочки в переменные и функции, потом все склеиваем. Для примера возьмем регулярочку из другой хабростатьи:

// 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();

Наконец для сложных штук, типа написания своих раскрашивателей кода или анализаторов 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 за то, что они начали отличную ветку о гибридном подходе в соседнем посте. Спасибо @jcmvbkbc: почти наверняка, именно читая его код, я впервые понял, что строки можно склеивать и в регулярках тоже.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Я видел в коде (или писал сам) декомпозированные регулярки
52.63% никогда не видел 10
21.05% видел пару раз 4
5.26% часто видел 1
10.53% постоянно декомпозирую и других учу 2
10.53% НЛО разложит меня на составные блоки, если я расскажу 2
Проголосовали 19 пользователей. Воздержались 2 пользователя.
Источник: https://habr.com/ru/post/693622/


Интересные статьи

Интересные статьи

Когда-то я стал писать на perl из-за встроенных в язык регулярных выражений. Просто пишешь "abc" =~ / (?<x> a) /x; и вуаля: устанавливаются переменные $&, $1, %+, $`, $'.Однако эти же переме...
Одна из самых важных (на мой взгляд) функций в Битрикс24 это бизнес-процессы. Теоретически они позволяют вам полностью избавиться от бумажных служебок и перенести их в эл...
Если вы считаете, что умеете готовить, то может, вы знаете о том, как сделать суфле или пахлаву, а может — и не знаете. Но есть кое-что такое, чему вы, вероятно, научились, просто готовя ...
В 1С-Битрикс: Управление сайтом (как и в Битрикс24) десятки, если не сотни настраиваемых типов данных (или сущностей): инфоблоки, пользователи, заказы, склады, форумы, блоги и т.д. Стр...
Ваш сайт работает на 1С-Битрикс? Каждому клиенту вы даёте собственную скидку или назначаете персональную цену на товар? Со временем в вашей 1С сложилась непростая логика ценообразования и формирования...