Artur
40 уровень
Tallinn

20 коротких шагов для освоения регулярных выражений. Часть 3

Статья из группы Random
RegEx: 20 коротких шагов для освоения регулярных выражений. Часть 1. RegEx: 20 коротких шагов для освоения регулярных выражений. Часть 2. В этой части мы перейдем к вещам еще чуть более сложным. Но, освоить их, как и прежде, не составит особого труда. Повторюсь, что RegEx на самом деле легче, чем он может показаться вначале, и не нужно быть семи пядей во лбу, чтобы его освоить, и начать применять на деле. Англоязычный оригинал этой статьи здесь. 20 коротких шагов для освоения регулярных выражений. Часть 3 - 1

Шаг 11: круглые скобки () как группы захвата

20 коротких шагов для освоения регулярных выражений. Часть 3 - 2В последней задаче мы искали различные виды целочисленных значений и числовых значений с плавающей запятой (точкой). Но механизм регулярных выражений не делал различий между этими двумя типами значений, поскольку все было отражено в одном большом регулярном выражении. Мы можем сказать движку регулярных выражений, что нужно различать разные виды совпадений, если заключим наши мини-шаблоны в круглые скобки:
pattern: ([A-Z])|([a-z])
string:  The current President of Bolivia is Evo Morales.
matches: ^^^ ^^^^^^^ ^^^^^^^^^ ^^ ^^^^^^^ ^^ ^^^ ^^^^^^^
group:   122 2222222 122222222 22 1222222 22 122 1222222  
(Пример) Приведенное выше регулярное выражение определяет две группы захвата, которые индексируются начиная с 1. Первая группа захвата соответствует любой отдельной заглавной букве, а вторая группа захвата соответствует любой отдельной строчной букве. Используя знак 'или' | и круглые скобки () как группу захвата, мы можем определить одно регулярное выражение, которое соответствует нескольким видам строк. Если мы применим это к нашему регулярному выражению для поиска long / float из предыдущей части статьи, то механизм регулярных выражений будет фиксировать соответствующие совпадения в соответствующих группах. Проверяя, какой группе соответствует подстрока, мы можем сразу определить, является ли она значением float или значением long:
pattern: (\d*\.\d+[fF]|\d+\.\d*[fF]|\d+[fF])|(\d+[lL])
string:  42L 12 x 3.4f 6l 3.3 0F L F .2F 0.
matches: ^^^      ^^^^ ^^     ^^     ^^^
group:   222      1111 22     11     111  
(Пример) Это регулярное выражение довольно сложное, и, чтобы его лучше понять, давайте разберем его на части, и рассмотрим каждый из этих шаблонов:
(                // совпадает с любой "float" подстрокой
  \d*\.\d+[fF]
  |
  \d+\.\d*[fF]
  |
  \d+[fF]
)
|               // OR
(               // совпадает с любой "long" подстрокой
  \d+[lL]
) 
Знак | и группы захвата в скобках () позволяют нам сопоставлять различные типы подстрок. В этом случае мы сопоставляем либо числа с плавающей запятой "float", либо длинные длинные целые числа "long".
(
  \d*\.\d+[fF]  // 1+ цифр справа от десятичной точки
  |
  \d+\.\d*[fF]  // 1+ цифр слева от десятичной точки
  |
  \d+[fF]       // без точки, только 1+ цифр
)
|
(
  \d+[lL]       // без точки, только 1+ цифр
)
В группе захвата "float" у нас есть три варианта: числа с минимум 1 цифрой справа от десятичной точки, числа с минимум одной цифрой слева от десятичной точки и числа без десятичной точки. Любые из них являются "float", при условии, что к их концу добавлены буквы "f" или "F". Внутри группы захвата "long" у нас есть только одна опция - у нас должна быть 1 или более цифр, за которыми следует символ "l" или "L". Механизм регулярных выражений будет искать эти подстроки в данной строке и индексировать их в соответствующей группе захвата. Обратите внимание, что мы не сопоставляем ни одно из чисел, к которым не добавлено ни одного из "l", "L", "f" или "F". Как следует классифицировать эти цифры? Ну, если они имеют десятичную точку, по умолчанию в языке Java используется значение "double". В противном случае они должны быть "int".

Закрепим пройденное парой задачек:

Добавьте еще две группы захвата к приведенному выше регулярному выражению, чтобы оно также классифицировало числа double или int. (Это еще один сложный вопрос, не расстраивайтесь, если это займет некоторое время, в крайнем случае смотрите мое решение.)
pattern: 
string:  42L 12 x 3.4f 6l 3.3 0F L F .2F 0.
matches: ^^^ ^^   ^^^^ ^^ ^^^ ^^     ^^^ ^^
group:   333 44   1111 33 222 11     111 22
(Решение) Следущая задачка немного по-проще. Используйте группы захвата в скобках (), знак 'или' | и диапазоны символов для сортировки следующих возрастов: "разрешено пить в США". (>= 21) и "запрещено пить в США" (<21):
pattern: 
string:  7 10 17 18 19 20 21 22 23 24 30 40 100 120
matches: ^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^^ ^^^
group:   2 22 22 22 22 22 11 11 11 11 11 11 111 111 
(Решение)

Шаг 12: сначала определите более конкретные совпадения

20 коротких шагов для освоения регулярных выражений. Часть 3 - 3Возможно, у вас возникли некоторые проблемы с последней задачей, если вы попытались определить "законно пьющих" как первую группу захвата, а не вторую. Чтобы понять почему, давайте посмотрим на другой пример. Предположим, мы хотим записать отдельно фамилии, содержащие менее 4 символов, и фамилии, содержащие 4 или более символов. Давайте отдадим более короткие имена первой группой захвата, и посмотрим что произойдет:
pattern: ([A-Z][a-z]?[a-z]?)|([A-Z][a-z][a-z][a-z]+)
string:  Kim Jobs Xu Cloyd Mohr Ngo Rock.
matches: ^^^ ^^^  ^^ ^^^   ^^^  ^^^ ^^^
group:   111 111  11 111   111  111 111   
(Пример) По умолчанию большинство движков регулярных выражений используют жадное сопоставление с основными символами, которые мы видели до сих пор. Это означает, что механизм регулярных выражений будет захватывать максимально длинную группу, определенную как можно раньше в предоставленном регулярном выражении. Таким образом, хотя вторая группа, приведенная выше, могла бы захватить больше символов в именах, таких как, например, "Jobs" и "Cloyd", но, поскольку первые три символа этих имен уже были захвачены первой группой захвата, они не могут быть захвачены снова второй. Теперь внесем небольшое исправление - просто изменим порядок групп захвата, поместив первой более конкретную (более длинную) группу:
pattern: ([A-Z][a-z][a-z][a-z]+)|([A-Z][a-z]?[a-z]?)
string:  Kim Jobs Xu Cloyd Mohr Ngo Rock.
matches: ^^^ ^^^^ ^^ ^^^^^ ^^^^ ^^^ ^^^^
group:   222 1111 22 11111 1111 222 1111    
(Пример)

Задача... на этот раз только одна :)

"Более конкретный" шаблон почти всегда означает "более длинный". Предположим, что мы хотим найти два вида "слов": сначала те, которые начинаются с гласных (более конкретно), потом те, которые не начинаются с гласных (любое другое слово). Попробуйте написать регулярное выражение для захвата и идентификации строк, которые соответствуют этим двум группам. (Группы ниже обозначены буквами, а не пронумерованы. Вы должны определить, какая группа должна соответствовать первой, а какая - второй.)
pattern: 
string:  pds6f uub 24r2gp ewqrty l ui_op
matches: ^^^^^ ^^^ ^^^^^^ ^^^^^^ ^ ^^^^^
group:   NNNNN VVV NNNNNN VVVVVV N VVVVV
(Решение) В общем, чем точнее ваше регулярное выражение, тем длиннее оно получится в итоге. И чем оно точнее, тем меньше вероятность, что вы захватите то, что вам не нужно. Поэтому, хотя они могут выглядеть пугающими, более длинные регулярные выражения ~= лучшие регулярные выражения. К сожалению.

Шаг 13: фигурные скобки {} для определенного числа повторений

20 коротких шагов для освоения регулярных выражений. Часть 3 - 4В примере с фамилиями из предыдущего шага у нас было 2 почти повторяющихся группы в одном шаблоне:
pattern: ([A-Z][a-z][a-z][a-z]+)|([A-Z][a-z]?[a-z]?)
string:  Kim Jobs Xu Cloyd Mohr Ngo Rock.
matches: ^^^ ^^^^ ^^ ^^^^^ ^^^^ ^^^ ^^^^
group:   222 1111 22 11111 1111 222 1111    
Для первой группы нам нужны были фамилии с четырьмя или более буквами. Вторая группа должна была захватывать фамилии с тремя или менее буквами. Существует-ли какой-то более простой способ написать это, чем повторять эти [a-z] группы снова и снова? Существует, если использовать для этого фигурные скобки {}. Фигурные скобки {} позволяют нам указать минимальное и (необязательно) максимальное количество совпадений предыдущего символа или группы захвата. Есть три варианта использования {}:
{X}   // совпадает точно X раз
{X,}  // совпадает >= X раз
{X,Y} // совпадает >= X and <= Y раз
Вот примеры этих трех различных синтаксисов:
pattern: [a-z]{11}
string:  humuhumunukunukuapua'a.
matches: ^^^^^^^^^^^   
(Пример)
pattern: [a-z]{18,}
string:  humuhumunukunukuapua'a.
matches: ^^^^^^^^^^^^^^^^^^^^    
(Пример)
pattern: [a-z]{11,18}
string:  humuhumunukunukuapua'a.
matches: ^^^^^^^^^^^^^^^^^^    
(Пример) В приведенных выше примерах есть несколько моментов, на которые следует обратить внимание:. Во-первых, используя нотацию {X}, предыдущий символ или группа будет соответствовать именно этому числу (X) раз. Если в "слове" есть больше символов (чем число X), которые могли бы соответствовать шаблону (как показано в первом примере), то они не будут включены в соответствие. Если количество символов меньше X, то полное сопоставление завершится неудачно (попробуйте изменить 11 на 99 в первом примере). Во-вторых, обозначения {X,} и {X,Y} являются жадными. Они будут пытаться соответствовать как можно большему числу символов, в то же время удовлетворяя заданному регулярному выражению. Если вы укажете {3,7}, то можно будет сопоставить от 3 до 7 символов, и если следующие 7 символов действительны, тогда будут сопоставлены все 7 символов. Если вы укажете {1,}, и все следующие 14 000 символов совпадают, то все 14 000 из этих символов будут включены в соответствующую строку. Как мы можем использовать это знание, чтобы переписать наше выражение выше? Самым простым улучшением может быть замена соседних групп [a-z] на [a-z]{N}, где N выбирается соответствующим образом:
pattern: ([A-Z][a-z]{2}[a-z]+)|([A-Z][a-z]?[a-z]?)  
... но это не делает ситуацию намного лучше. Посмотрите на первую группу захвата: у нас есть [a-z]{2} (что соответствует ровно 2 строчным буквам), за которыми следует [a-z]+ (что соответствует 1 или более строчным буквам). Мы можем упростить это, запросив 3 или более строчных букв, используя фигурные скобки:
pattern: ([A-Z][a-z]{3,})|([A-Z][a-z]?[a-z]?) 
Вторая группа захвата отличается. Нам нужно не более трех символов в этих фамилиях, что означает, что у нас есть верхний предел, но наш нижний предел равен нулю:
pattern: ([A-Z][a-z]{3,})|([A-Z][a-z]{0,2}) 
Специфичность всегда лучше при использовании регулярных выражений, поэтому было бы разумно остановиться на этом, но я не могу не заметить, что эти два диапазона символов ([AZ] и [az]) рядом друг с другом выглядят почти как класс "word character" (символ слова), \w ([A-Za-z0-9_]). Если мы уверены, что наши данные содержат только хорошо отформатированные фамилии, то мы могли бы упростить наше регулярное выражение, и написать просто:
pattern: (\w{4,})|(\w{1,3}) 
Первая группа захватывает любую последовательность из 4 или более "word characters" ([A-Za-z0-9_]), а вторая группа захватывает любую последовательность от 1 до 3 "word characters" (включительно). Сработает-ли это?
pattern: (\w{4,})|(\w{1,3})
string:  Kim Jobs Xu Cloyd Mohr Ngo Rock.
matches: ^^^ ^^^^ ^^ ^^^^^ ^^^^ ^^^ ^^^^
group:   222 1111 22 11111 1111 222 1111    
(Пример) Сработало! Как насчет такого подхода? И это намного чище, чем в нашем предыдущем примере. Поскольку первая группа захвата соответствует всем фамилиям с четырьмя или более символами, то мы могли бы даже изменить вторую группу захвата просто на \w+, так как это позволило бы нам захватить все оставшиеся фамилии (с 1, 2 или 3 символами):
pattern: (\w{4,})|(\w+)
string:  Kim Jobs Xu Cloyd Mohr Ngo Rock.
matches: ^^^ ^^^^ ^^ ^^^^^ ^^^^ ^^^ ^^^^
group:   222 1111 22 11111 1111 222 1111    
(Пример)

Давайте поможем мозгу усвоить это, и решим следующие 2 задачи:

Используйте фигурные скобки {}, чтобы переписать регулярное выражение для поиска номера социального страхования из шага 7:
pattern:
string:  113-25=1902 182-82-0192 H23-_3-9982 1I1-O0-E38B
matches:             ^^^^^^^^^^^
(Решение) Предположим, что система проверки надежности пароля на веб-сайте требует, чтобы пароли пользователей составляли от 6 до 12 символов. Напишите регулярное выражение, помечающее неверные пароли в списке ниже. Каждый пароль содержится в скобках () для удобства сопоставления, поэтому убедитесь, что регулярное выражение начинается и заканчивается буквальными (и) символами. Подсказка: убедитесь, что вы запрещаете литеральные скобки в паролях с помощью [^()] или аналогичных, в противном случае вы получите в конечном итоге соответствие всей строке!
pattern: 
string:  (12345) (my password) (Xanadu.2112) (su_do) (OfSalesmen!)
matches: ^^^^^^^ ^^^^^^^^^^^^^               ^^^^^^^  
(Решение)

Шаг 14: \b символ границы нулевой ширины

20 коротких шагов для освоения регулярных выражений. Часть 3 - 5Последняя задача была довольно сложной. Но что, если мы еще немного усложним ее, заключив пароли в кавычки "" вместо скобок ()? Можем ли мы написать аналогичное решение, просто заменив все символы круглых скобок на символы кавычек?
pattern: \"[^"]{0,5}\"|\"[^"]+\s[^"]*\"
string:  "12345" "my password" "Xanadu.2112" "su_do" "OfSalesmen!"
matches: ^^^^^^^ ^^^^^^^^^^^^^             ^^^     ^^^  
(Пример) Получилось не очень впечатляюще. Вы уже догадались почему? Проблема в том, что мы ищем здесь неправильные пароли. "Xanadu.2112" - хороший пароль, поэтому, когда регулярное выражение понимает, что эта последовательность не содержит пробелов или литеральных символов ", оно сдается непосредственно перед символом ", который ограничивает пароль в правой части. (Поскольку мы указали, что символы " не могут быть найдены внутри паролей, используя [^"].) Как только механизм регулярных выражений убедится, что эти символы не соответствуют определенному регулярному выражению, он запускается снова, именно в том месте, где он остановился - где был символ ", который ограничивает "Xanadu.2112" справа. Оттуда он видит один символ пробела, и другой символ " - для него это неправильный пароль! В общем, он находит эту последовательность " " и идет дальше. Это совсем не то, что мы хотели-бы получить... Было бы здорово, если бы мы могли указать, что первый символ пароля должен быть не пробелом. Есть-ли способ сделать это? (К настоящему моменту, вы, наверное, уже поняли, что ответом на все мои риторические вопросы является "да".) Да! Такой способ есть! Многие движки регулярных выражений предоставляют такую escape-последовательность как "граница слова" \b. "Граница слова" \b - это escape-последовательность нулевой ширины, которая, как ни странно, соответствует границе слова. Помните, что когда мы говорим "слово", то мы имеем в виду как любую последовательность символов в классе \w, так и такую [A-Za-z0-9_]. Совпадение по границе слова означает, что символ непосредственно перед, или сразу после последовательности \b должен быть не символом слова. Однако, при сопоставлении, мы не включаем этот символ в нашу захваченную подстроку. Это и есть нулевая ширина. Чтобы увидеть, как это работает, давайте рассмотрим небольшой пример:
pattern: \b[^ ]+\b
string:  Ve still vant ze money, Lebowski.
matches: ^^ ^^^^^ ^^^^ ^^ ^^^^^  ^^^^^^^^  
(Пример) Последовательность [^ ] должна соответствовать любому символу, который не является символом буквального пробела. Так почему же это не соответствует запятой , после money или точке ". после Lebowski? Это потому что запятая , и точка . не являются символами слова, поэтому между символами слова и несловесными символами создаются границы. Они появляются между y в конце слова money и запятой , которая следует за ним, и между "i слова Lebowski и точкой . (полной остановкой / периодом), которая следует за ним. Регулярное выражение совпадает на границах этих слов (но не на несловесных символах, которые только помогают их определить). Но что произойдет, если мы не включим последовательность \b в наш шаблон?
pattern: [^ ]+
string:  Ve still vant ze money, Lebowski.
matches: ^^ ^^^^^ ^^^^ ^^ ^^^^^^ ^^^^^^^^^  
(Пример) Ага, теперь мы находим и эти знаки препинания. Теперь давайте воспользуемся границами слов, чтобы исправить регулярное выражение для паролей в кавычках:
pattern: \"\b[^"]{0,5}\b\"|\"\b[^"]+\s[^"]*\b\"
string:  "12345" "my password" "Xanadu.2112" "su_do" "OfSalesmen!"
matches: ^^^^^^^ ^^^^^^^^^^^^^               ^^^^^^^  
(Пример) Размещая границы слов внутри кавычек ("\b ... \b"), мы фактически говорим, что первый и последний символы совпадающих паролей должны быть "символами слова". Так что здесь все работает нормально, но не будет работать так же хорошо, если первый или последний символ пароля пользователя не является символом слова:
pattern: \"\b[^"]{0,5}\b\"|\"\b[^"]+\s[^"]*\b\"
string:  "thefollowingpasswordistooshort" "C++"
matches:   
(Пример) Посмотрите, как второй пароль не помечен как "неверный", даже если он явно слишком короткий. Вы должны быть осторожны с последовательностями \b, так как они соответствуют границам только между символами \w и не \w. В приведенном выше примере, поскольку в паролях мы допустили символы не \w, граница между \ и первым/последним символом пароля не гарантируется как граница слова \b.

В завершение этого шага решим только одну простую задачу:

Границы слова полезны в механизмах подсветки синтаксиса, когда мы хотим сопоставить определенную последовательность символов, но хотим убедиться, что они встречаются только в начале или конце слова (или сами по себе). Предположим, мы пишем подсветку синтаксиса и хотим выделить слово var, но только тогда, когда оно появляется само по себе (не касаясь других символов слова). Сможете ли вы написать регулярное выражение для этого? Конечно сможете, ведь это совсем простая задача ;)
pattern: 
string:  var varx _var (var j) barvarcar *var var-> {var}
matches: ^^^            ^^^               ^^^ ^^^    ^^^  
(Решение)

Шаг 15: "caret" ^ как "начало строки" и знак доллара $ как "конец строки"

20 коротких шагов для освоения регулярных выражений. Часть 3 - 6Последовательность границ слова \b (из последнего шага предыдущей части статьи) - не единственная специальная последовательность нулевой ширины, доступная для использования в регулярных выражениях. Двумя наиболее популярными из них являются "caret" ^ - "начало строки" и знак доллара $ - "конец строки". Включение одного из них в ваши регулярные выражения означает, что данное совпадение должно появиться в начале или в конце исходной строки:
pattern: ^start|end$
string:  start end start end start end start end
matches: ^^^^^                               ^^^  
(Пример) Если ваша строка содержит разрывы строк, то ^start будет соответствовать последовательности "start" в начале любой строки, а end$ будет соответствовать последовательности "end" в конце любой строки (хотя это трудно показать здесь). Эти символы особенно полезны при работе с данными, которые содержат разделители. Давайте вернемся к проблеме "размера файла" из шага 9, используя ^ "начало строки". В этом примере наши размеры файлов разделены пробелами " ". Поэтому мы хотим, чтобы каждый размер файла начинался с цифры, которой предшествует символ пробела или начало строки:
pattern: (^| )(\d+|\d+\.\d+)[KMGT]B
string:  6.6KB 1..3KB 12KB 5G 3.3MB KB .6.2TB 9MB.
matches: ^^^^^       ^^^^^   ^^^^^^          ^^^^
group:   222         122     1222            12    
(Пример) Мы уже так близко к цели! Но вы можете заметить, что у нас все еще есть одна небольшая проблема: мы сопоставляем символ пробела перед допустимым размером файла. Теперь мы можем просто игнорировать эту группу захвата (1), когда наш движок регулярных выражений найдет ее, или мы можем использовать группу без захвата, которую мы увидим на следующем шаге.

А пока, решим еще 2 задачки для тонуса:

Продолжая наш пример подсветки синтаксиса из последнего шага, некоторые подсветки синтаксиса будут отмечать конечные пробелы, то есть любые пробелы, которые находятся между непробельным символом и концом строки. Можете ли вы написать регулярное выражение для подсветки только конечных пробелов?
pattern: 
string:  myvec <- c(1, 2, 3, 4, 5)  
matches:                          ^^^^^^^  
(Решение) Простой синтаксический анализатор с разделителями-запятыми (CSV) будет искать "токены", разделенные запятыми. Как правило, пробел не имеет значения, если он не заключен в кавычки "". Напишите простое регулярное выражение для разбора CSV, которое сопоставляет токены между запятыми, но игнорирует (не захватывает) пробел, который не находится между кавычек.
pattern: 
string:  a, "b", "c d",e,f,   "g h", dfgi,, k, "", l
matches: ^^ ^^^^ ^^^^^^^^^^   ^^^^^^ ^^^^^^ ^^ ^^^ ^
group:   21 2221 2222212121   222221 222211 21 221 2    
(Решение) RegEx: 20 коротких шагов для освоения регулярных выражений. Часть 4.
Комментарии (12)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Олег Уровень 35, Москва, Russian Federation
24 мая 2022
Блин, есть где 16й шаг по понятней объясняется? А то я что то поплыл на нем.
antlantis Уровень 25
5 января 2022
Ребята, всем привет! кто-нибудь может подсказать, почему на шаге 13 в задаче с паролями регулярное выражение имеет вид - \([^()]{0,5}\)|\([^()]+\s[^()]*\) ? почему выражение [^()]{0,5} вообще ищет какие-то символы? где указание искать символы? где \w ? почему [^()] означает искать всё, кроме круглых скобок? и что входит в это "искать всё" ? цифры, буквы, пробелы?
Vladimir Уровень 39, Тольятти, Россия
26 июня 2021
Почему в 13 задаче (Подсветить пароли) в примере подсвечен пароль (my password)? - он 11 символов, и по условиям задачи проходит. В условии задачи не видно что "пробел" не может быть символом пароля.
Евгения Хоменкова Уровень 26, Гомель, Беларусь
28 апреля 2021
Не очень поняла, почему в решении задачи шага12 слова на гласные идут во второй группе. Ведь смысл вроде как сначала искать более конкретные вещи, а потом уже все что осталось. Ну то есть вот так:

([eyuioa]\w*)|(\w+)
Alukard Уровень 36, London Expert
30 августа 2020
В последней задачке у автора почему-то пробелы подсвечивает. Хотя по заданию не должно. У меня вышло такое решение

(,)|([^ ]["a-z ]*)
Андрей Шевченко Уровень 41, Москва, Россия
8 мая 2020
В последней задаче 11 шага, кажется, неверная регулярка. Вторая группа определяется по 1 цифре. ([0-9]|1[0-9]|20) - т.е. первый ИЛИ покрывает все. Я сделал так: ([1-9][0-9]|[0-9])