Глава 13: Работа с регулярными выражениями в Rust

Содержание: Введение в регулярные выражения: синтаксис, назначение Использование библиотеки regex Создание и компиляция шаблонов: Regex::new, обработка ошибок Группы захвата и извлечение данных Итерация по совпадениям Замена текста Производительность: кэширование Regex, ленивая компиляция Примеры Упражнение: Извлечение email-адресов из текстового файла

Сегодня мы погрузимся в увлекательный мир регулярных выражений (regular expressions, или regex) — мощного инструмента для обработки текста, поиска совпадений, извлечения данных и замены строк. Мы разберём всё от основ до продвинутых техник, используя библиотеку regex в Rust, и завершим лекцию практическим упражнением. Приступим!


1. Введение в регулярные выражения: синтаксис, назначение

Что такое регулярные выражения?

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

Зачем они нужны в Rust?

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

Основы синтаксиса

Синтаксис регулярных выражений в Rust основан на стандарте Perl (PCRE, Perl-Compatible Regular Expressions), но с некоторыми ограничениями для обеспечения производительности. PCRE — это широко используемый стандарт, и вы можете найти его полную документацию здесь: PCRE Documentation. Вот ключевые элементы синтаксиса:

Пример: ^\d{2}-\w+$ — строка начинается с двух цифр, затем дефис, затем одно или более буквенно-цифровых символов до конца строки.

Жадный vs Не жадный захват

Многострочный пример


2. Использование библиотеки regex

Подключение через Cargo

Для работы с регулярными выражениями в Rust используется crate regex. Добавьте его в ваш Cargo.toml:

[dependencies]
regex = "1.10"
    

После этого выполните cargo build, чтобы скачать и скомпилировать библиотеку.

Основные методы

Библиотека предоставляет структуру Regex, которая компилирует шаблон и позволяет выполнять операции с текстом. Вот ключевые методы:

Пример:

use regex::Regex;

fn main() {
    let re = Regex::new(r"\d+").unwrap(); // Ищет одну или более цифр
    let text = "Rust 2025";

    println!("Есть совпадение? {}", re.is_match(text)); // true
    if let Some(mat) = re.find(text) {
        println!("Найдено: {}", mat.as_str()); // "2025"
    }
}
    

r перед строкой в Regex::new(r"asd") означает "сырая строка".
Это упрощает работу с регулярными выражениями, позволяя писать \ без экранирования.
Без r тебе пришлось бы писать \\d, \\s, \\b, удваивая слэши, чтобы они дошли до библиотеки regex в правильном виде.
Сырые строки избавляют от этой необходимости, делая код чище и понятнее.


3. Создание и компиляция шаблонов: Regex::new, обработка ошибок

Создание объекта Regex

Метод Regex::new принимает строку с шаблоном и возвращает Result<Regex, Error>. Если шаблон синтаксически неверен, вы получите ошибку.

use regex::Regex;

fn main() {
    match Regex::new(r"[a-z+") {
        Ok(_) => println!("Шаблон корректен"),
        Err(e) => println!("Ошибка: {}", e), // Ошибка: незакрытая скобка
    }
}
    

Обработка ошибок

Всегда обрабатывайте ошибки, чтобы ваш код был надёжным. Используйте unwrap() только в примерах или если вы уверены в корректности шаблона.

use regex::Regex;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let re = Regex::new(r"\d+")?;
    println!("Шаблон скомпилирован!");
    Ok(())
}
    

4. Группы захвата и извлечение данных

Что такое группы захвата?

Группы, заключённые в (), позволяют извлечь части совпадения. Например, в шаблоне (\d+)-(\w+) первая группа — цифры, вторая — слово. Есть два типа групп:

Пример с кодом

use regex::Regex;

fn main() {
    // Захватывающая группа
    let re_capture = Regex::new(r"(\w+)-(\d+)").unwrap();
    let text = "dog-123";
    if let Some(caps) = re_capture.captures(text) {
        println!("Группа 1 (захват): {}", &caps[1]); // "dog"
        println!("Группа 2 (захват): {}", &caps[2]); // "123"
    }

    // Незахватывающая группа
    let re_non_capture = Regex::new(r"(?:\w+)-(\d+)").unwrap();
    if let Some(caps) = re_non_capture.captures(text) {
        println!("Группа 1 (захват): {}", &caps[1]); // "123"
        // caps[2] недоступно, т.к. (?:\w+) не захватывает
    }
}
    

Бытовой пример

Представь, что ты ищешь в магазине "коробку с конфетами" или просто "конфеты". Захватывающая группа (коробка)-(конфеты) — это как если ты записываешь и коробку, и конфеты. Незахватывающая (?:коробка)-(конфеты) — это когда тебе важны только конфеты, а коробка нужна лишь для поиска, но ты её не запоминаешь.

Использование captures

Метод captures возвращает структуру Captures, из которой можно извлечь группы по индексу или имени.

use regex::Regex;

fn main() {
    let re = Regex::new(r"(\d+)-(\w+)").unwrap();
    let text = "123-rust";

    if let Some(caps) = re.captures(text) {
        println!("Полное совпадение: {}", &caps[0]); // "123-rust"
        println!("Первая группа: {}", &caps[1]);     // "123"
        println!("Вторая группа: {}", &caps[2]);     // "rust"
    }
}
    

Именованные группы

Вы можете дать группам имена с помощью синтаксиса (?P<имя>шаблон):

use regex::Regex;

fn main() {
    let re = Regex::new(r"(?P<number>\d+)-(?P<word>\w+)").unwrap();
    let text = "456-code";

    if let Some(caps) = re.captures(text) {
        println!("Число: {}", caps.name("number").unwrap().as_str()); // "456"
        println!("Слово: {}", caps.name("word").unwrap().as_str());   // "code"
    }
}
    

5. Итерация по совпадениям

find_iter

Для поиска всех совпадений используйте find_iter:

use regex::Regex;

fn main() {
    let re = Regex::new(r"\d+").unwrap();
    let text = "Year 2023, version 15";

    for mat in re.find_iter(text) {
        println!("Найдено: {}", mat.as_str()); // "2023", "15"
    }
}
    

captures_iter

Для извлечения групп из всех совпадений:

use regex::Regex;

fn main() {
    let re = Regex::new(r"(\d+)-(\w+)").unwrap();
    let text = "123-rust 456-code";

    for caps in re.captures_iter(text) {
        println!("Число: {}, Слово: {}", &caps[1], &caps[2]);
    }
    // Вывод:
    // Число: 123, Слово: rust
    // Число: 456, Слово: code
}
    

6. Замена текста

replace и replace_all

Методы replace заменяет первое совпадение, а replace_all — все совпадения.

use regex::Regex;

fn main() {
    let re = Regex::new(r"\d+").unwrap();
    let text = "Year 2023, version 15";

    let replaced = re.replace(text, "XX");
    println!("Первая замена: {}", replaced); // "Year XX, version 15"

    let replaced_all = re.replace_all(text, "XX");
    println!("Полная замена: {}", replaced_all); // "Year XX, version XX"
}
    

Использование групп в замене

Можно ссылаться на группы с помощью $n или $имя:

use regex::Regex;

fn main() {
    let re = Regex::new(r"(\d+)-(\w+)").unwrap();
    let text = "123-rust";

    let result = re.replace(text, "$2-$1");
    println!("Результат: {}", result); // "rust-123"
}
    

7. Производительность: кэширование Regex, ленивая компиляция

Кэширование Regex

Компиляция шаблона — дорогая операция. Создавайте объект Regex один раз и переиспользуйте его:

use regex::Regex;

fn process_text(text: &str, re: &Regex) {
    if re.is_match(text) {
        println!("Совпадение найдено!");
    }
}

fn main() {
    let re = Regex::new(r"\d+").unwrap();
    process_text("Rust 2025", &re);
    process_text("No numbers", &re);
}
    

Ленивая компиляция с lazy_regex

Для ещё большей оптимизации используйте crate lazy_regex, который компилирует шаблон только при первом использовании:

[dependencies]
lazy_regex = "3.1"
    
use lazy_regex::regex;

fn main() {
    let re = regex!(r"\d+"); // Компиляция отложена
    println!("{}", re.is_match("Rust 2025")); // true
}
    

8. Примеры

Парсинг логов

Допустим, у нас есть лог:

2025-03-28 10:15:32 ERROR: Disk full
2025-03-28 10:16:01 INFO: Backup started
    

Извлечём дату и сообщение:

use regex::Regex;

fn main() {
    let log = "2025-03-28 10:15:32 ERROR: Disk full\n2025-03-28 10:16:01 INFO: Backup started";
    let re = Regex::new(r"(?m)^(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2} (\w+): (.+)$").unwrap();

    for caps in re.captures_iter(log) {
        println!("Дата: {}, Уровень: {}, Сообщение: {}", &caps[1], &caps[2], &caps[3]);
    }
}
    

Примечание: Флаг (?m) делает ^ и $ привязанными к началу и концу каждой строки.

Валидация ввода

Проверка формата email:

use regex::Regex;

fn is_valid_email(email: &str) -> bool {
    let re = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
    re.is_match(email)
}

fn main() {
    println!("{}", is_valid_email("user@example.com")); // true
    println!("{}", is_valid_email("invalid@.com"));     // false
}
    

Жадный и не жадный захват

use regex::Regex;

fn main() {
    let text = "<tag>content</tag><tag>more</tag>";
    let greedy = Regex::new(r"<tag>.*</tag>").unwrap();
    let lazy = Regex::new(r"<tag>.*?</tag>").unwrap();

    println!("Жадный: {:?}", greedy.find(text).unwrap().as_str()); // "<tag>content</tag><tag>more</tag>"
    println!("Не жадный: {:?}", lazy.find(text).unwrap().as_str()); // "<tag>content</tag>"
}
    

9. Упражнение: Извлечение email-адресов из текстового файла

Задача

Напишите программу, которая читает текстовый файл и извлекает все email-адреса, используя регулярные выражения. Сохраните их в вектор и выведите.

Решение

Создайте файл emails.txt с содержимым:

Contact: user1@example.com
Invalid: not-an-email
Support: support@domain.co.uk, sales@domain.com
    

Код:

use regex::Regex;
use std::fs;

fn extract_emails(file_path: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(file_path)?;
    let re = Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap();

    let emails: Vec<String> = re.find_iter(&content)
        .map(|mat| mat.as_str().to_string())
        .collect();

    Ok(emails)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let emails = extract_emails("emails.txt")?;
    if emails.is_empty() {
        println!("Email-адреса не найдены.");
    } else {
        println!("Найденные email-адреса:");
        for email in emails {
            println!("- {}", email);
        }
    }
    Ok(())
}
    

Объяснение

  1. Читаем файл с помощью fs::read_to_string.
  2. Используем шаблон для email (упрощённый, но рабочий).
  3. Собираем совпадения в вектор с помощью find_iter.
  4. Обрабатываем ошибки через Result.

Вывод

Найденные email-адреса:
- user1@example.com
- support@domain.co.uk
- sales@domain.com
    

Заключение

Мы подробно разобрали работу с регулярными выражениями в Rust: от расширенного синтаксиса (включая \w, \W, жадность и многострочность) и базовых методов до продвинутых техник, таких как группы захвата, итерация и замена. Вы узнали, как оптимизировать производительность и решать практические задачи. Теперь вы готовы применять эти знания в своих проектах! Попробуйте усложнить упражнение, добавив валидацию email или поддержку более сложных форматов.