Регулярные выражения в Julia
Введение
Регулярные выражения (regex) — это универсальный инструмент для поиска, извлечения и обработки текста по заданным шаблонам. Они позволяют решать задачи вроде проверки формата email, извлечения номеров телефонов или анализа данных из текстов. В языке Julia регулярные выражения особенно удобны благодаря простоте их интеграции и лаконичному синтаксису. Julia не требует дополнительных модулей для работы с regex (в отличие от некоторых других языков) и использует префикс r"..." для создания шаблонов, что делает код интуитивным и читаемым. Этот материал покажет, как применять регулярные выражения в Julia, с акцентом на практические примеры и особенности языка.
Синтаксис регулярных выражений в Julia
Julia использует синтаксис PCRE, который поддерживает богатый набор возможностей. Разберём основные элементы регулярных выражений и их применение в Julia.
Базовые элементы синтаксиса
- Литералы: Обычные символы (например,
a,b,1) ищутся в тексте как есть. Например,r"cat"соответствует словуcat. - Метасимволы:
.— любой символ, кроме новой строки. Например,r"c.t"соответствуетcat,cot, но неc\nt.^— начало строки:r"^hello"найдётhelloтолько в начале строки.$\text{—} \text{конец} \text{строки}:r"world$"найдётworldтолько в конце строки.\— экранирование:r"\."ищет точку как символ, а не любой символ.r"\^"ищет именно символ^.
- Квантификаторы:
*— ноль или более:r"a*"соответствует"",a,aaи т.д.+— одно или более:r"a+"соответствуетa,aa, но не"".?— ноль или одно:r"colou?r"соответствуетcolorиcolour.{n,m}— диапазон повторений:r"a{2,4}"соответствуетaa,aaa,aaaa.
- Классы символов:
[abc]— один из символов:r"[abc]"соответствуетa,bилиc.[a-z]— диапазон:r"[a-z]+"найдёт любое слово из строчных букв.[^abc]— отрицание:r"[^abc]"любой символ, кромеa,b,c.\d— цифра:r"\d+"найдёт числа вроде123.\w— буква, цифра или_:r"\w+"найдёт слова вродеhello123.\s— пробельный символ:r"\s+"найдёт пробелы или табуляции.
- Группировка:
()— захватывает часть шаблона:r"(\d+)-(\d+)"выделит из строки12-34числа12и34и сохранит.(?:...)— группирует, но не "сохранит" результат. Незахватывающая группа полезна для упрощения структуры выражения.
regex = r"(?:abc)+(\d+)(?:def)+(\&+)"
text = "abcabc123defdefdef&&&"
match(regex,text)[1] # вернёт "123"
match(regex,text)[2] # вернёт "&&&"
Функции в Julia
Julia предлагает удобные методы для работы с регулярными выражениями:
match(r"шаблон", строка): Находит первое совпадение. Возвращает объектRegexMatchилиnothing.
Особенность Julia — отсутствие необходимости в двойном экранировании (например,
r"\d"вместо"\\d"), что упрощает написание шаблонов.
m = match(r"\d+", "Возраст: 42") #\d+ `\d` - выбери цифру `+` - одно или больше вхождений
println(m.match)
eachmatch(r"шаблон", строка): Итератор всех совпадений.
for m in eachmatch(r"\w+", "Здравствуй, дорогой друг!") # `\w` - буква, цифра или _ (`,` и `!` не подходят)
println(m.match)
end
replace(строка, r"шаблон" => "замена"): Заменяет совпадения.
new = replace("Формат даты: 01-02-2025", r"\d" => "X")
println(new)
occursin(r"шаблон", строка): Проверяет наличие совпадения.
@show occursin(r"[A-Z]", "Hello")
@show occursin(r"[A-Z]", "hello")
@show occursin(r"[A-Z]+", "HELLO");
Практические применеие
Пример 1: извлечение имени и фамилии
Пускай была сделана фотография журнала посетителей консерватории.
Далее мы оцифровали этот документ и выполнили распознавание текста, получив "документ" OCR_text.
И вышло так, что некоторые буквы стали в нижнем регистре, где-то добавились лишние пробелы, а где-то исчезли. В каких-то случаях росчерк распознался как буква.
# Текст с данными
OCR_text = """
Журнал посетителей:
Фамилия: иванов имя: Иван
фамилия : Петров имя : пётр l
Фамилия - Римский-Корсаков Имя -Николай
"""
Указав флаг:
iв выраженииr"..."i- мы указываем независимость от регистра (case-insensitive). То есть "фамилия" и "Фамилия" будут считаться равнозначнымиmв выраженииr"..."mозначает многострочность.^в выражении будет означать начало строки после каждого\n, а не просто начало "большой" строкиOCR_text.xв выраженииr"..."x- мы можем использовать пробелы и указывать комментарии через#(x от слова extended)
Значение круглых скобок обсудим ниже.
regex_fullname = r"
^Фамилия\s* # `Фамилия` в начале строки, а после 0 или более пробелов
[:-]\s* # далее один знак `:` или `-` и 0 или более пробелов
([\p{L}-]+) # [\p{L}-]+ - `\p{L}` - символы Unicode, `-` - дефис
\s* # после фамилии снова 0 или более пробелов
Имя\s*[:-]\s* # то же, что и с фамилией
(\p{L}+) # Любая последовательность букв (русских в том числе) это и есть имя"imx;
Для того, чтобы извлечь полезную информацию из нашего документа OCR_text,используя регулярное выражение regex_fullname, воспользуемся eachmatch.
Заметим, что у нас есть 3 человека. И у каждого человека по 2 характеристики: Фамилия и Имя.
eachmatch возвращает итератор, содержащий объекты типа RegexMatch, где каждый объект представляет одно совпадение шаблона в тексте.
Наш шаблон в себе содержит фамилию и имя. Фамилия в выражении у нас идёт первой, поэтому для фамилий мы будем использовать m.captures[1]. Имя же у нас является вторым
Т.е. мы создали массив кортежей из фамилии и имени посетителей.
fullnames = [(m.captures[1], m.captures[2]) for m in eachmatch(regex_fullname, OCR_text)]
Выведем имена и фамилии, в формате Заголовка:
titlecase("abc"); # Abc
titlecase("aBC"); # Abc
for (surname, name) in fullnames
println("Здравствуйте, $(titlecase(surname)) $(titlecase(name))!")
end
Пример 2: Извлечение номеров телефонов
Допустим, нам нужно найти номер в формате +7-XXX-XXX-XX-XX или 8-XXX-XXX-XX-XX:
Пояснение:
\d{3}означает ровно 3 цифры,
\+экранирует плюс как литерал.
|означает или
(?:...)- "незахватывающая группа", т.е. это такая подчасть выражения, которую мы хотим отдельно определить (+7 или 8, а дальше уже набор цифр и дефисов).Но сама информация, записан ли телефон через +7 или через 8 нас НЕ интересует. Поэтому она и НЕзахватывающая.
text1 = "Российские номера это +7-912-345-67-89 или 8-987-654-32-10, но не +1-234-567-89-10"
russian_phone_regex = r"(?:\+7|8)-\d{3}-\d{3}-\d{2}-\d{2}"
for m in eachmatch(russian_phone_regex, text1)
println("Найден российский номер: ", m.match)
end
Пример 3: Проверка email-адресов
Проверим корректность email:
email = "test_User-name.123@pochta.ru"
email_regex = r"^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}$"
if match(email_regex, email) !== nothing
println("Email корректен")
else
println("Некорректный email")
end
Пояснение:
^[a-zA-Z0-9._-]+требует имя пользователя из букв, цифр и некоторых символов, а\.[a-z]{2,}$— домен верхнего уровня длиной 2+ символа.
Пример 4: Обработка сносок на литературу
Извлечём сноски вида [1], [1, 2]:
text2 = "Текст ссылается на [1], [2, 3] и [4], и содержит математические выражения: 1 + (2{3 - x[y-z]})."
ref_regex = r"\[\d+(?:,\s*\d+)*\]"
matches = [m.match for m in eachmatch(ref_regex, text2)]
println("Сноски: ", join(matches, ", "))
Пояснение: (?:,\s*\d+)* — незахватывающая группа для чисел с запятыми.
Пример 5: Получениее соннетов Шекспира
Пусть даны сонеты Уильяма Шекспира, пронумерованные римскими цифрами. Создадим пронумерованный в исходном порядке массив этих сонетов, чтобы можно было легко к ним обратиться по индексу.
sonnets_text = read("sonnets.txt",String);
print(sonnets_text[1:1000])
В сонетах присутствуют переносы строк. Поэтому точку не получится использовать для обозначения любого символа (посмотрите начало главы "Регулярные выржания"). Чтобы это обойти воспользуемся:
\s означает пробельный символ.
\S означает НЕ непробельный символ
А значит [\s\S] означает любой символ.
function split_sonnets(text)
pattern = r"""
^ # Начало строки (с флагом m — для каждой строки)
\s* # Ноль или более пробелов перед римской цифрой
[IVXLCDM]+ # Одна или более римских цифр (I, V, X, L, C, D, M)
\s* # Ноль или более пробелов после цифры
$ # Конец строки (ограничивает строку только цифрой)
\s* # Пробелы или пустые строки после цифры
#___________________________________________________________________________________________________
( # Начало захватывающей группы для текста сонета
[\s\S]*? # Любой символ (включая \n), нежадно (до ближайшей остановки)
) # Конец захватывающей группы
#___________________________________________________________________________________________________
(?= # Положительный просмотр вперёд (условие остановки)
^ # Начало следующей строки
\s* # Пробелы перед следующей цифрой
[IVXLCDM]+ # Следующая римская цифра
\s* # Пробелы после неё
$ # Конец строки с цифрой
| # Или
\z # Абсолютный конец текста (для последнего сонета)
) # Конец просмотра вперёд
"""mx # Флаги: m (многострочный режим), x (расширенный режим)
sonnets = [strip(m.captures[1]) for m in eachmatch(pattern, text)]
return sonnets
end
sonnets = split_sonnets(sonnets_text)
Выведем теперь только первую строчку первых пяти сонетов.
Для это разделим, используя split каждый сонет на 2 части таким образом:
- 1 часть: первая строка
- 2 часть: все последующие строки, кроме первой
s = """1 строка
2 строка
3 строка
4 строка"""
split(s,'\n',limit=2)
for (i, sonnet) in enumerate(sonnets[1:5])
println("""Соннет $i:$(split(sonnet,'\n',limit=2)[1])\n...""")
end
Измерим скорость выполнения нашей функции.
Pkg.add("BenchmarkTools")
using BenchmarkTools
@btime split_sonnets(sonnets_text);
1.24 миллисекунды это довольно хороший результат. для файла из 2.5 тысяч строк. Однако нужно понимать, что регулярные выражения могут уступать классическим подходам. В нашем случае мы могли решить задачу довольно явным способом. (Но можно в него и не погружаться, а посмотреть на скорость его выполнения)
function split_sonnets_fast(text)
sonnets = String[]
current_sonnet = String[]
in_sonnet = false
for line in eachline(text)
if !isempty(line) # Проверяем до отбрасывания пробелов и \n через функцию strip
stripped = strip(line)
# Если каждый (all) символ строки принадлежит IVXLCDM, то это римское число
if all(c -> c in "IVXLCDM", stripped)
if in_sonnet && !isempty(current_sonnet)
push!(sonnets, join(current_sonnet, '\n'))
end
current_sonnet = String[]
in_sonnet = true
elseif in_sonnet
push!(current_sonnet, line)
end
end
end
if in_sonnet && !isempty(current_sonnet)
push!(sonnets, join(current_sonnet, '\n'))
end
return sonnets
end
# Проверка работы
sonnets = split_sonnets_fast("sonnets.txt")
@btime split_sonnets_fast("sonnets.txt");
Заключение
Регулярные выражения в Julia — это мощный и удобный инструмент для работы с текстом. Благодаря простоте синтаксиса (r"..."), встроенным функциям вроде match и replace, а также высокой производительности языка, они идеально подходят для задач обработки данных и анализа, поиска и замены. Но важно понимать, что регулярные выражения могут работать медленно для задач парсинга сложных (вложенных, например) структур, таких как JSON-файлы, HTML-файлы и пр.
И несмотря на некоторую сложность синтаксиса регулярных выражений, благодаря расширениям можно делать комментарии. Что является более универсальным инструментом для работы с текстом, чем встроенных функции работы с символами и строками в Julia.