habrahabr

Регулярные выражения простыми словами. Часть 2

  • пятница, 6 декабря 2024 г. в 00:00:11
https://habr.com/ru/companies/selectel/articles/863670/

Разработчики делятся на три типа: те, кто уже понимает регулярные выражения и порой решает сложные задачи одной строкой; те, кто все еще боится их и всячески избегает; и те, кто уже прочитал первую часть этой серии статей и полон оптимизма разобраться с этими магическими письменами. Эта статья специально для третьих, чтобы им было проще стать первыми.

Используйте навигацию, если не хотите читать текст полностью:

Пред(исловие|остережение)
Диалекты
Что угодно — и точка
Лекарство от жадности
Альфа и Омега
О квадратном
Опции
Финал

Пред(исловие|остережение)


В предыдущей статье моей основной задачей было показать, что регулярные выражения — это не так страшно, как обычно представляют. Увы, это не совсем так. Регулярные выражения прошли долгий путь от чисто математической абстракции до конкретных реализаций и в процессе обросли массой подробностей, которые не всегда логичны.

Чтобы было проще к этому относиться, нужно воспринимать регулярки как некое тайное знание. А в любом тайном знании всегда есть контринтуитивные штуки, которые невозможно понять логикой. Только зазубрить. Все ради того, чтобы посвященные могли снисходительно смотреть на непосвященных и на вопрос «А почему так?» отвечать: «Просто потому что здесь так принято».

Диалекты


Важно понимать, что не существует единого стандарта регулярных выражений. Чтобы регулярки было сложно не только читать, но и писать, каждая школа магии добавила в них что-то от себя. В результате существует с десяток основных диалектов (по английски flavors — привкусов) и несколько десятков специфичных.

Отличия между диалектами самые разные: некоторые фичи могут отсутствовать вовсе; некоторые присутствуют, но имеют отличающийся синтаксис; некоторые имеют одинаковый синтаксис, но работают по-разному. Короче, скучать не придется.

Зачем столько диалектов? Люди задавались этим вопросом на StackOverflow еще 15 лет назад. Ситуация с тех пор лучше не стала.

Однако, не пугайтесь. Разбираться в тонкостях ароматов, букетов и терруаров пока не будем. Разберем базовые ванильные вещи, более-менее общие для большинства диалектов. Однако, не стоит слепо верить всему, что написано в этой статье — всегда сверяйтесь с документацией вашего языка программирования.

К счастью, Regex101 поддерживает несколько основных диалектов. А Regexper, увы, — только диалект JavaScript.


Что угодно — и точка


Один из наиболее популярных, но опасных специальных символов — точка. Точка означает вообще любой символ кроме переноса строки. Такой джокер в мире регулярок.

Соответственно, следующее регулярное выражение означает «что угодно сколько угодно раз». То есть ему соответствует вообще любая строка, от пустой до пентиллиардов смеющихся смайликов:

.*


Например, мы хотим найти все слова, которые начинаются на «к» и заканчиваются на «а». Самый очевидный подход:

к.*а

Получаем:


Вроде прекрасно работает, даже для слов с дефисом и несколькими «а» в середине. Так в чем же опасность? Дело в том, что тесты на картинке специально подобраны так, чтобы можно было сказать: «на моей машине все работает». Однако, в реальности все не так очевидно.


Совсем не то, что хотели. Вместо отдельных слов попадаются и части слова, и части соседних слов, и даже целые предложения.

Причин несколько:

  • Точка действительно означает любой символ, в том числе пробел.
  • Мы никак не указали, что хотим отдельные слова.
  • Звездочка жадная.



Лекарство от жадности


Как известно, на данный момент человечество алгоритмически реализовало только два смертных греха из семи: жадность и лень, остальные пять пока ожидают своего часа.

Все квантификаторы (*, + и ?) по умолчанию являются «жадными», то есть стараются загрести как можно больше подходящих символов. Если можно взять — надо брать, такой у них принцип.

Рассмотрим стандартную ошибку, допускаемую при написании регулярок. Например, мы хотим найти в коде все строковые константы, то есть то, что начинается и заканчивается двойными кавычками.

".*"


Ситуация та же, что и в предыдущем примере. Как видим, любой символ между кавычек — это действительно любой символ, в том числе и сами кавычки. Один из способов это исправить — сделать квантификаторы ленивыми. Делается это при помощи добавления знака вопроса (?) после квантификатора.

  • *? — ленивая звездочка
  • +? — ленивый плюс
  • ?? — ленивый вопрос

У ленивых принцип обратный: если можно уйти пораньше с работы — надо уходить. При первой же подходящей возможности они останавливаются.

".*?"


Теперь работает так, как задумывалось.

Для наглядности посмотрим, как это выглядит на графе (обратите внимание на стрелки).

Жадный. Приоритетное направление — повторить, даже если встречаем кавычку.


Ленивый. Приоритетное направление — пропустить, как только встречаем кавычку.


Жадность можно отключать и для фигурных скобок по тому же принципу. Например, выражение a{2,5}? будет искать от двух до пяти букв «а» подряд, но уже без особого энтузиазма.

Альфа и Омега


Как мы неоднократно видели, строка, соответствующая регулярному выражению, может находиться в середине текста. И это хорошо! Но только в том случае, когда мы выполняем поиск. Однако это не так хорошо, когда нужно выполнить валидацию.

Для простоты рассмотрим максимально нереалистичный пример. Допустим, мы хотим с помощью регулярных выражений проверять пароль. Разумеется, никогда не стоит этого делать в реальной жизни! Однако статья у нас имеет скорее развлекательный, чем поучительный характер, поэтому почему бы и нет. И, раз уж начали делать опрометчивые поступки, то и пароль будет password.

Соответственно, если строка соответствует регулярному выражению password, то и пароль правильный. Вроде бы логично. Проблема в том, что во многих языках для того, чтобы строка считалась валидной, она не обязательно должна вся соответствовать регулярному выражению. Достаточно только какой-то подстроки! Поэтому password1234, passwordpassword, 111password111 и так далее — все это будет считаться правильным паролем.


Не особо безопасно! Как это исправить (помимо того, что не стоит использовать регулярки для проверки пароля)? Нужно указать, что у нас ничего не должно быть ни до, ни после искомой строки.

Для этого есть две руны:

^ — руна дома, символизирует рождение, свет, начало строки.

$ — руна доллара, символизирует крах, тьму, конец строки.

Соответственно, такому выражению подойдет только строка password без каких-либо приставок и окончаний.

^password$


Теперь подходит только password.

Идем дальше. Как и в жизни, дом и доллар не обязательно должны быть вместе, в некоторых случаях достаточно чего-то одного. Например, выражение ^однажды соответствует слову «однажды» только в начале строки,


А выражение и поделом$ соответствует словосочетанию «и поделом» только в конце строки.


Обратите внимание, что выражению будут соответствовать только указанные слова, а не вся строка. Если интересует строка целиком, начинающаяся на «однажды», можно написать так:

^однажды.*


И соответственно:

.*и поделом$


Помимо спецсимволов для начала и конца строки, есть спецсимвол для начала/конца слова — \b (от слова boundary). Однако, у него достаточно странные понятия о том, что считать словом, а что нет. Об этом поговорим чуть позже.

О квадратном


Как уже говорилось в предыдущей статье, квадратные скобки позволяют перечислить набор интересующих нас символов.

Например, [aeiou] означает «любая английская гласная» (в нижнем регистре).


А выражение [0123456789] означает «любая десятичная цифра».


Если нужны не только гласные, а вообще все буквы английского алфавита, то, конечно, можно написать и так:

[abcdefghijklmnopqrstuvwxyz]

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

[a-z]

Соответственно, для цифр можно написать:

[0-9]

Логично предположить, что и для русских букв можно написать:

[а-я]

Конечно же, это не так. Задавая интервал, мы имеем в виду все символы, чьи коды находятся в указанном промежутке. Те, у кого в ФИО есть буква «ё», наверняка уже поняли, куда я клоню: [а-я] означает все русские буквы, кроме «ё».


К счастью, перечисления и интервалы можно совмещать. Выражение для всех русских букв, включая «ё», будет выглядеть так:

[а-яё]


Интервалов тоже можно указать несколько, например для шестнадцатеричных цифр можно написать:

[0-9a-f]

Но есть нюанс: не только шестнадцатеричные цифры удовлетворяют выражению.


Ну и, конечно, можно совмещать несколько интервалов и перечислений. Любую букву английского и русского алфавитов обоих регистров можно записать следующим образом (обратите внимание, что если не используется опция Case Insensitive, то буквы разных регистров нужно прописывать явно):

[a-zа-яёA-ZА-ЯЁ]


Инверсия


Иногда набор символов настолько велик, что проще указать, что в него не должно входить. Помните опасную точку, которая означает любой символ? Иногда нужно указать «любой символ, кроме…». В таком случае сразу после открывающей скобки ставим ^. Да-да, та самая руна дома, которая во внешнем мире означала начало строки, внутри квадратных скобок означает отрицание. Не спрашивайте, почему так, просто здесь так принято. Тайное знание, как никак.

Примеры:

  • [^aeiou] — что угодно кроме гласных букв (в том числе и вовсе не буквы),
  • [^0-9] — что угодно кроме цифр,
  • [^”] — что угодно кроме кавычки.

Если нужно использовать «домик» как обычный символ, можно просто поставить его не в начало строки, а куда-нибудь в середину. Например, [!@#$%^&*] означает, что кошка прошла по клавиатуре.

Экранирование


Внутреннее содержимое квадратных скобок — это свой отдельный мир, в котором действуют свои законы физики, не такие, как во внешнем мире. Например, большинство спецсимволов здесь теряют сверхспособности.

То есть [+*?.] — это именно плюс, звездочка, вопросительный знак или точка — обычные символы, несмотря на то, что их не пришлось экранировать.


Но тут есть ловушка. Выражение ниже может показаться перечислением арифметических операций: умножение, вычитание и сложение:

[*-+]

Однако минус здесь не просто символ, а спецсимвол, задающий интервал. Получается, что на самом деле здесь задан интервал от «*» (код 42) до «+» (код 43), то есть включает в себя только звездочку и плюс, но не минус.


Если же поменять порядок, чтобы это уже не было интервалом, то минус теряет сверхспособность:

[-*+]


Таким образом, позиция минуса влияет на его смысл: в начале или в конце это просто символ, а в середине означает интервал. С домиком «^» все наоборот: в середине списка это просто символ, а в начале означает инверсию. Забавное (хотя, если задуматься, вполне логичное) исключение: если в начале стоят и домик, и минус, то домик будет спецсимволом, а минус — обычным символом.

Примеры:

  • [^-] — все, кроме минуса,
  • [-^] — минус или домик,
  • [^-+] — все, кроме минуса и плюса,
  • [^+-] — все, кроме минуса и плюса,
  • [-^+] — минус, домик или плюс,
  • [-+^] — минус, домик или плюс,
  • [+^-] — минус, домик или плюс,
  • [+-^] — внезапно! интервал от + (код 43) до ^ (код 94), включающий в себя цифры, заглавные буквы и другие символы!

Некоторые символы экранировать все-таки придется. Например, что делать, если нужно использовать квадратные скобки внутри квадратных скобок? К счастью, косая черта работает и здесь:

[\[\]]


Косая черта работает и в обратную сторону: если поставить ее перед обычным символом, он станет «особенным»:

[a-z\s] — буквы или пробел, или таб, или перенос строки


Встроенные наборы


Некоторые наборы символов настолько популярны, что для них придумали сокращенные варианты.

Из первой части статьи о регулярках мы уже знаем \s, который означает пробел или таб. Любая цифра — это \d (от digit) = [0-9]. Латинская буква, цифра или подчеркивание — \w (от word) = [a-zA-Z0-9_].

Обратите внимание, что \w включает подчеркивание, но не включает никакие алфавиты помимо латинского, поэтому больше подходит для языков программирования (да и то не для всех), чем для естественных. Вобщем, используйте осмотрительно.


Помните, мы упоминали \b? Так вот, он означает границу между \s и \w (в любом порядке). Начало строки также является границей слова. Однако, так как \w считает цифры и подчеркивание частью слова, а любые «иностранные» буквы не считает частью слова, то и \b работает не совсем так, как хотелось бы. Просто здесь так принято.


У каждого из этих суперсимволов есть злой двойник.

  • \S — что угодно, кроме пробелов, табов и переносов строк,
  • \D — что угодно, кроме цифр,
  • \W — что угодно, кроме [a-zA-Z0-9_],
  • \B — то место, где точно нет разрыва слова.

Многабукв


Помните, мы говорили, что инструкции действуют только на один символ предшествующей инструкции? Так вот, вся конструкция с квадратными скобками считается одним символом (и в принципе, так оно и есть).

  • [A-Z]{4} — четыре заглавные буквы, например HOPE, BEAF или XHZF;
  • [A-Z0-9]{4} — четыре заглавные буквы или цифры, например R2D2;
  • #[0-9a-fA-F]{6} — цвет в шестнадцатеричном формате, например #e0e0e0;
  • \d+ — целое неотрицательное число, непример 123;
  • [-+]?\d+ — целое число с опциональным знаком, например, 123, +123, -123;
  • \w+ — последовательность из английских букв, цифр или подчеркиваний, например int_2_str или 123password;
  • \b[_a-zA-Z]\w+\b — идентификатор в некоторых языках программирования. Отличается от предыдущего примера тем, что не может начинаться с цифры и является целым словом;
  • “[^”]*” — текст в кавычках, здесь вместо ленивого режима мы используем класс «что угодно кроме кавычек»;
  • к[а-яёА-ЯЁ]*а — последовательность кириллических букв, начинающаяся на «к» и оканчивающаяся на «а», но не обязательно слово. Может быть и часть слова;
  • \bк[а-яёА-ЯЁ]*а\b — хотелось бы, чтобы это, наконец, стало отдельным словом, но, увы, нет — \b так не работает.

Опции


Во многих движках можно указывать опции регулярного выражения. Их набор и смысл могут отличаться, поэтому рассмотрим только самые распространенные.

  • i — case insensitive,
  • g — global,
  • m — multiline,
  • s — single line.

Case Insensitive переводится как «чемодан бесчувственный», и название говорит само за себя. Обратите внимание, что если эта опция присутствует, нечувствительность к регистру относится только к тексту, на спецсимволы это никак не влияет, то есть \s и \S продолжают означать противоположные вещи.

Global означает глобальный поиск. Без этой опции будет найдено только первое подходящее соответствие. А с ней — все возможные соответствия.

Multiline влияет на то, как воспринимаются спецсимволы начала и конца строки: ^ и $, точнее, что считать строкой. В английском языке есть два слова: string (как последовательность символов) и line (как элемент текста). В русском словосочетание «многострочная строка» звучит как масло масляное, поэтому используем слово «текст». Итак, если опция отключена, то ^ — начало всего текста, $ — конец всего текста. Если опция включена, то ^ — начало каждой строки текста, $ — конец каждой строки текста.

Single line — неудачно названная опция, потому что может показаться, что она является антонимом к multiline, но это не так. Multiline и single line могут быть активны одновременно, они не взаимоисключающие. Исторически так сложилось, что точка означает любой символ, кроме переноса строки. Так вот, опция single line изменяет это поведение: если опция включена, то точка означает вообще все, в том числе и перенос строки, что делает ее еще мощнее и опаснее.

Финал


Давайте соберем все вместе и все-таки решим задачу с козой и капустой. Что нам нужно?

  1. Слово начинается на «к», это может быть как начало, так и середина строки.
  2. Заканчивается на «а», опять же это может быть как конец строки, так и ее середина.
  3. Между «к» и «а» могут стоять кириллические буквы и дефис.
  4. Это должно быть целое слово, а не часть.
  5. Мы не можем использовать \b, потому что кириллица.

Можно разделить выражение на пять частей:
  1. То, что идет перед словом: начало строки или НЕ буква.
  2. Буква «к».
  3. Середина слова: несколько букв или дефисов.
  4. Буква «а».
  5. То, что идет после слова: конец строки или НЕ буква.

  • Буква: [а-яё].
  • Буква или дефис: [а-яё-].
  • Антибуква: [^а-яё].
  • Антибуква или начало строки: (^|[^а-яё]).
  • Антибуква или конец строки: ($|[^а-яё]).


Собираем все вместе, и получаем типичную запись в паспорте Джейсона Борна:

(^|[^а-яё])к[а-яё-]+а($|[^а-яё])

Вот так это выглядит на графе:



Почти работает! Единственная оставшаяся проблема: выражение захватывает не только слово, но и символ перед и после него. Как это исправить, обсудим в последующих статьях.

А на этом пока все. Квадратное обсудили, в следующей части поговорим о круглом.