Регулярное выражение пишут один раз, а читают потом годами. Пишет его человек, который держит задачу в голове целиком; читает — тот, у кого в голове совсем другое, и обычно в неудачный момент. Разница между этими двумя людьми и определяет, чего стоит выражение.
Почему они становятся нечитаемыми
Не потому, что синтаксис плохой. Потому что он поощряет уплотнение: любое усложнение задачи добавляет несколько символов в ту же строку, и строка растёт, не меняя формы. Код при усложнении разбухает и требует разбиения на функции — выражение просто становится длиннее.
Второе свойство: у выражения нет промежуточных состояний. Функцию можно читать по шагам, выражение приходится удерживать целиком. Чем оно длиннее, тем больше нужно удерживать, и в какой-то момент этот объём перестаёт помещаться.
Отсюда практический вывод, из которого следует всё остальное: выражение надо не сокращать, а разбирать на части — так же, как код.
Тупик: добавить символов, пока не заработает
Первое, что пробуют, когда выражение не ловит нужный случай, — дописать альтернативу или сделать часть необязательной. Заработало на этом примере, поехали дальше.
Это не помогает, потому что каждая такая правка расширяет то, что выражение принимает, и никто не проверяет, что оно перестало принимать лишнее. Через несколько итераций выражение ловит нужное и заодно много ненужного, а обнаруживается это на данных, которых при отладке не было.
Есть и обратный вариант той же ошибки: сузить выражение под конкретный неудачный пример. Оно перестаёт ловить и половину правильных случаев, но их в тестовом наборе не оказалось.
Общая причина в обоих случаях — правка вслепую: меняют выражение, не имея набора примеров, на котором видно и что должно ловиться, и что не должно.
Что делает выражение поддерживаемым
Именованные группы вместо номеров. Обращение к третьей группе по номеру ломается при добавлении четвёртой в середине. Имя не ломается и заодно объясняет, что там лежит.
Многострочная запись с комментариями. Большинство языков умеет игнорировать пробелы и переводы строк внутри выражения. Тогда его можно записать столбиком, по смысловому куску на строку, с пояснением рядом. Это то же самое выражение, только читаемое.
Разбиение на части. Выражение можно собирать из именованных кусков, объявленных отдельно. Кусок «дата», кусок «идентификатор», кусок «разделитель» — каждый читается и проверяется сам по себе, а целое становится их сборкой.
Явные границы. Выражение без привязки к началу и концу строки ловит совпадение в любом месте, включая то, где его не ждали. Большая часть неожиданных срабатываний — отсюда.
Осторожность с ненасытностью. Конструкция, которая берёт «сколько получится», по умолчанию берёт слишком много и останавливается на последнем совпадении, а не на первом. Это не ошибка, это документированное поведение, но оно противоречит интуиции почти всех, кто читает выражение впервые.
Точка, которая совпадает не со всем. Символ «любой» по умолчанию не включает перевод строки, и выражение, отлаженное на однострочном примере, ведёт себя иначе на многострочном входе. Это одна из немногих вещей, где поведение отличается между языками, поэтому проверять его надо в своём, а не помнить из чужой статьи.
Пробелы, которых не видно. Табуляция, неразрывный пробел, символ возврата каретки в конце строки — всё это выглядит одинаково в редакторе и по-разному для выражения. Значительная часть случаев «не совпадает, хотя должно» объясняется именно этим, и лечится не правкой выражения, а нормализацией входа до него.
Где регулярные выражения — неправильный инструмент
Вложенные структуры. Разметка, скобки, вложенные кавычки. Выражение по своей природе не умеет считать вложенность, и попытка обойти это даёт конструкцию, которая работает на трёх уровнях и ломается на четвёртом. Для таких данных берут разбор, а не поиск.
Форматы, у которых есть библиотека. Адреса электронной почты, номера телефонов, даты, ссылки. Все они выглядят простыми и все имеют пограничные случаи, которых не ожидают. Готовая библиотека уже наступила на них, ваше выражение — ещё нет.
Проверка того, что важно проверить по-настоящему. Выражение говорит, что строка похожа на адрес. Оно не говорит, что адрес существует. Для второго нужен другой механизм, и подменять одно другим — типичный источник ложной уверенности.
Как их проверять
Так же, как код: набором примеров, где записано и что должно совпасть, и что не должно. Второй список важнее первого — именно он ловит расширение при правках.
Набор стоит держать рядом с выражением, а не в голове. Тогда следующая правка становится проверяемой: изменил, прогнал, увидел, что ничего лишнего не проехало.
Примеры для этого набора берутся не из воображения, а из данных. Выгрузите сотню реальных строк, разметьте руками, что должно совпасть, и прогоняйте выражение на них. Воображаемые примеры проверяют то, что автор уже предусмотрел; реальные приносят случаи, которых он не ждал, — и именно они ломают выражение в бою.
Отдельно стоит проверить поведение на длинной строке, которая почти совпадает. Некоторые конструкции при таком входе начинают перебирать варианты, и время растёт непропорционально длине. Это не теоретическая опасность: если выражение применяется к данным, пришедшим снаружи, это способ уронить обработку.
Признак опасной конструкции узнаётся глазами: две вложенные повторяющиеся группы, между которыми возможно неоднозначное разбиение. Если такое встретилось в выражении, применяемом к внешним данным, стоит либо ограничить длину входа, либо переписать выражение так, чтобы разбиение стало однозначным.
Полезно и ограничить время выполнения там, где язык это позволяет. Выражение, работающее дольше положенного, почти всегда означает не сложные данные, а неудачную конструкцию — и лучше узнать об этом по сработавшему ограничению, чем по остановившейся обработке очереди.
Позиция редакции
Мы считаем, что регулярное выражение длиннее одной строки должно записываться столбиком с комментариями и сопровождаться набором примеров — включая примеры того, что совпадать не должно. Практический вывод: если выражение нельзя объяснить вслух за полминуты, его надо не сокращать, а разбирать на именованные части.
Мы неправы, если выражение одноразовое: для разового поиска по файлу в редакторе вся эта обвязка — лишняя работа, там выражение живёт минуту и умирает вместе с задачей. Граница проходит там, где выражение попадает в код и переживает своего автора.