Регулярные выражения в Julia

Автор
avatar-igarajaigaraja
Notebook

Регулярные выражения в 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 только в начале строки.
    • $ — конец строки: 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"), что упрощает написание шаблонов.

In [ ]:
m = match(r"\d+", "Возраст: 42")  #\d+  `\d` - выбери цифру  `+` - одно или больше вхождений
println(m.match)
42
  • eachmatch(r"шаблон", строка): Итератор всех совпадений.
In [ ]:
for m in eachmatch(r"\w+", "Здравствуй, дорогой друг!")  # `\w` - буква, цифра или _  (`,` и `!` не подходят)
    println(m.match)
end
Здравствуй
дорогой
друг
  • replace(строка, r"шаблон" => "замена"): Заменяет совпадения.
In [ ]:
new = replace("Формат даты: 01-02-2025", r"\d" => "X")
println(new)
Формат даты: XX-XX-XXXX
  • occursin(r"шаблон", строка): Проверяет наличие совпадения.
In [ ]:
@show occursin(r"[A-Z]", "Hello")
@show occursin(r"[A-Z]", "hello")
@show occursin(r"[A-Z]+", "HELLO");
occursin(r"[A-Z]", "Hello") = true
occursin(r"[A-Z]", "hello") = false
occursin(r"[A-Z]+", "HELLO") = true

Практические применеие

Пример 1: извлечение имени и фамилии

Пускай была сделана фотография журнала посетителей консерватории. Далее мы оцифровали этот документ и выполнили распознавание текста, получив "документ" OCR_text. И вышло так, что некоторые буквы стали в нижнем регистре, где-то добавились лишние пробелы, а где-то исчезли. В каких-то случаях росчерк распознался как буква.

In [ ]:
# Текст с данными
OCR_text = """
Журнал посетителей:
Фамилия: иванов имя: Иван
фамилия : Петров имя : пётр     l
Фамилия - Римский-Корсаков Имя  -Николай    
"""

Указав флаг:

  • i в выражении r"..."i - мы указываем независимость от регистра (case-insensitive). То есть "фамилия" и "Фамилия" будут считаться равнозначными
  • m в выражении r"..."m означает многострочность. ^ в выражении будет означать начало строки после каждого \n, а не просто начало "большой" строки OCR_text.
  • x в выражении r"..."x - мы можем использовать пробелы и указывать комментарии через # (x от слова extended)

Значение круглых скобок обсудим ниже.

In [ ]:
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]. Имя же у нас является вторым

Т.е. мы создали массив кортежей из фамилии и имени посетителей.

In [ ]:
fullnames = [(m.captures[1], m.captures[2]) for m in eachmatch(regex_fullname, OCR_text)]
Out[0]:
3-element Vector{Tuple{SubString{String}, SubString{String}}}:
 ("иванов", "Иван")
 ("Петров", "пётр")
 ("Римский-Корсаков", "Николай")

Выведем имена и фамилии, в формате Заголовка:

titlecase("abc"); # Abc
titlecase("aBC"); # Abc
In [ ]:
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 нас НЕ интересует. Поэтому она и НЕзахватывающая.

In [ ]:
text = "Российские номера это +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, text)
    println("Найден российский номер: ", m.match)
end
Найден российский номер: +7-912-345-67-89
Найден российский номер: 8-987-654-32-10

Пример 3: Проверка email-адресов

Проверим корректность email:

In [ ]:
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
Email корректен

Пояснение: ^[a-zA-Z0-9._-]+ требует имя пользователя из букв, цифр и некоторых символов, а \.[a-z]{2,}$ — домен верхнего уровня длиной 2+ символа.

Пример 4: Обработка сносок на литературу

Извлечём сноски вида [1], [1, 2]:

In [ ]:
text = "Текст ссылается на [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, text)]
println("Сноски: ", join(matches, ", "))
Сноски: [1], [2, 3], [4]

Пояснение: (?:,\s*\d+)* — незахватывающая группа для чисел с запятыми.

Пример 5: Получениее соннетов Шекспира

Пусть даны сонеты Уильяма Шекспира, пронумерованные римскими цифрами. Создадим пронумерованный в исходном порядке массив этих сонетов, чтобы можно было легко к ним обратиться по индексу.

In [ ]:
sonnets_text = read("sonnets.txt",String);
print(sonnets_text[1:1000])
THE SONNETS

by William Shakespeare




  I

  From fairest creatures we desire increase,
  That thereby beauty's rose might never die,
  But as the riper should by time decease,
  His tender heir might bear his memory:
  But thou, contracted to thine own bright eyes,
  Feed'st thy light's flame with self-substantial fuel,
  Making a famine where abundance lies,
  Thy self thy foe, to thy sweet self too cruel:
  Thou that art now the world's fresh ornament,
  And only herald to the gaudy spring,
  Within thine own bud buriest thy content,
  And tender churl mak'st waste in niggarding:
    Pity the world, or else this glutton be,
    To eat the world's due, by the grave and thee.

  II

  When forty winters shall besiege thy brow,
  And dig deep trenches in thy beauty's field,
  Thy youth's proud livery so gazed on now,
  Will be a tatter'd weed of small worth held:
  Then being asked, where all thy beauty lies,
  Where all the treasure of thy lusty days;
  To say, within thine own deep

В сонетах присутствуют переносы строк. Поэтому точку не получится использовать для обозначения любого символа (посмотрите начало главы "Регулярные выржания"). Чтобы это обойти воспользуемся:

\s означает пробельный символ.

\S означает НЕ непробельный символ

А значит [\s\S] означает любой символ.

In [ ]:
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
Out[0]:
split_sonnets (generic function with 1 method)
In [ ]:
sonnets = split_sonnets(sonnets_text)
Out[0]:
154-element Vector{SubString{String}}:
 "From fairest creatures we desir" ⋯ 579 bytes ⋯ "'s due, by the grave and thee."
 "When forty winters shall besieg" ⋯ 597 bytes ⋯ "arm when thou feel'st it cold."
 "Look in thy glass and tell the " ⋯ 576 bytes ⋯ "nd thine image dies with thee."
 "Unthrifty loveliness, why dost " ⋯ 558 bytes ⋯ "sed, lives th' executor to be."
 "Those hours, that with gentle w" ⋯ 593 bytes ⋯ "r substance still lives sweet."
 "Then let not winter's ragged ha" ⋯ 580 bytes ⋯ "est and make worms thine heir."
 "Lo! in the orient when the grac" ⋯ 552 bytes ⋯ "n diest unless thou get a son."
 "Music to hear, why hear'st thou" ⋯ 612 bytes ⋯ "'Thou single wilt prove none.'"
 "Is it for fear to wet a widow's" ⋯ 585 bytes ⋯ " such murd'rous shame commits."
 "For shame! deny that thou bear'" ⋯ 591 bytes ⋯ "ill may live in thine or thee."
 "As fast as thou shalt wane, so " ⋯ 645 bytes ⋯ "t more, not let that copy die."
 "When I do count the clock that " ⋯ 594 bytes ⋯ " him when he takes thee hence."
 "O! that you were your self; but" ⋯ 574 bytes ⋯ "a father: let your son say so."
 ⋮
 "Lo, as a careful housewife runs" ⋯ 597 bytes ⋯ "back and my loud crying still."
 "Two loves I have of comfort and" ⋯ 542 bytes ⋯ "ad angel fire my good one out."
 "Those lips that Love's own hand" ⋯ 475 bytes ⋯ "v'd my life, saying 'not you'."
 "Poor soul, the centre of my sin" ⋯ 576 bytes ⋯ "d, there's no more dying then."
 "My love is as a fever longing s" ⋯ 563 bytes ⋯ "ack as hell, as dark as night."
 "O me! what eyes hath Love put i" ⋯ 594 bytes ⋯ "g thy foul faults should find."
 "Canst thou, O cruel! say I love" ⋯ 548 bytes ⋯ "e thou lov'st, and I am blind."
 "O! from what power hast thou th" ⋯ 576 bytes ⋯ "orthy I to be belov'd of thee."
 "Love is too young to know what " ⋯ 576 bytes ⋯ "ose dear love I rise and fall."
 "In loving thee thou know'st I a" ⋯ 602 bytes ⋯ "ainst the truth so foul a lie!"
 "Cupid laid by his brand and fel" ⋯ 578 bytes ⋯ "t new fire; my mistress' eyes."
 "The little Love-god lying once " ⋯ 563 bytes ⋯ "s water, water cools not love."

Выведем теперь только первую строчку первых пяти сонетов.

Для это разделим, используя split каждый сонет на 2 части таким образом:

  • 1 часть: первая строка
  • 2 часть: все последующие строки, кроме первой
In [ ]:
s = """1 строка
       2 строка
       3 строка
       4 строка"""
split(s,'\n',limit=2)
Out[0]:
2-element Vector{SubString{String}}:
 "1 строка"
 "2 строка\n3 строка\n4 строка"
In [ ]:
for (i, sonnet) in enumerate(sonnets[1:5])
    println("""Соннет $i:$(split(sonnet,'\n',limit=2)[1])\n...""")
end
Соннет 1:
From fairest creatures we desire increase,
...
Соннет 2:
When forty winters shall besiege thy brow,
...
Соннет 3:
Look in thy glass and tell the face thou viewest
...
Соннет 4:
Unthrifty loveliness, why dost thou spend
...
Соннет 5:
Those hours, that with gentle work did frame
...

Измерим скорость выполнения нашей функции.

In [ ]:
Pkg.add("BenchmarkTools")
In [ ]:
using BenchmarkTools
@btime split_sonnets(sonnets_text);
  1.244 ms (621 allocations: 39.19 KiB)

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

In [ ]:
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");
  888.738 μs (3870 allocations: 392.40 KiB)

Заключение

Регулярные выражения в Julia — это мощный и удобный инструмент для работы с текстом. Благодаря простоте синтаксиса (r"..."), встроенным функциям вроде match и replace, а также высокой производительности языка, они идеально подходят для задач обработки данных и анализа, поиска и замены. Но важно понимать, что регулярные выражения могут работать медленно для задач парсинга сложных (вложенных, например) структур, таких как JSON-файлы, HTML-файлы и пр.

И несмотря на некоторую сложность синтаксиса регулярных выражений, благодаря расширениям можно делать комментарии. Что является более универсальным инструментом для работы с текстом, чем встроенных функции работы с символами и строками в Julia.