Как стать автором
Обновить

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

Время на прочтение7 мин
Количество просмотров6.9K

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

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

/^(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.76% никогда не видел67
28.03% видел пару раз37
3.79% часто видел5
8.33% постоянно декомпозирую и других учу11
9.09% НЛО разложит меня на составные блоки, если я расскажу12
Проголосовали 132 пользователя. Воздержались 25 пользователей.
Теги:
Хабы:
Всего голосов 23: ↑15 и ↓8+12
Комментарии51

Публикации

Истории

Работа

Java разработчик
336 вакансий
React разработчик
68 вакансий
PHP программист
117 вакансий

Ближайшие события

12 – 13 июля
Геймтон DatsDefense
Онлайн
14 июля
Фестиваль Selectel Day Off
Санкт-ПетербургОнлайн
19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн