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

от автора

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

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

/^(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% никогда не видел 17
32.35% видел пару раз 11
2.94% часто видел 1
5.88% постоянно декомпозирую и других учу 2
8.82% НЛО разложит меня на составные блоки, если я расскажу 3
Проголосовали 34 пользователя. Воздержались 5 пользователей.

ссылка на оригинал статьи https://habr.com/ru/post/693622/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *