🦀 Rust

Expressions régulières en Rust : guide détaillé

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты — библиотекарь, и у тебя есть огромное хранилище с миллионами книг. Однажды приходит читатель и говорит: «Мне нужны все книги, где на обложке есть слово "Приключения", а внутри — хотя бы одна глава про пиратов, и чтобы книга была написана не позднее 2000 года».

Если бы ты просматривал каждую книгу вручную, это заняло бы недели. Но у тебя есть волшебная лупа, которая умеет читать описания книг со скоростью света. Ты просто говоришь лупе: «Найди все книги, где в названии есть слово "Приключения", в содержании — "пираты", а год издания начинается на "199" или меньше». Лупа мгновенно просматривает все записи и выдает тебе список.

Регулярные выражения (Regular Expressions, или сокращенно regex) — это и есть такая «волшебная лупа» для текста. Это специальный язык шаблонов, который позволяет:

  • Находить фрагменты текста по сложным условиям
  • Проверять, соответствует ли строка определенному формату (например, email или номер телефона)
  • Заменять части текста по шаблону
  • Разбивать текст на части по указанным разделителям

В реальной жизни мы постоянно используем регулярные выражения, даже не замечая этого. Когда ты ищешь файлы по маске *.docx в проводнике Windows, когда вводишь в поисковике site:example.com "ключевая фраза" — это всё разновидности регулярных выражений.

Важно: Регулярные выражения — это не особенность какого-то одного языка программирования. Это универсальный инструмент, который есть в Python, JavaScript, Rust, Go, Java и даже в текстовых редакторах вроде VS Code или Sublime Text. Различается только синтаксис подключения и некоторые детали, но логика везде одинаковая.

Теория: как это работает изнутри

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

Конечный автомат — «мозг» регулярного выражения

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

Этот процесс называется backtracking (возврат). Именно из-за него регулярные выражения могут работать медленно на очень больших текстах, если шаблон составлен неэффективно.

Символьные классы — кирпичики шаблона

Любое регулярное выражение строится из символьных классов. Вот основные из них:

Символ Значение Пример
. Любой символ, кроме перевода строки к.т найдёт «кот», «кит», «к_т»
d Любая цифра (0-9) dd найдёт «42», «00», «99»
w Любая буква, цифра или подчёркивание w+ найдёт целое слово
s Любой пробельный символ (пробел, табуляция, перевод строки) s+ найдёт все пробелы подряд
[abc] Любой символ из перечисленных [кмн]от найдёт «кот», «мот», «нот»
[^abc] Любой символ, кроме перечисленных [^к]от найдёт «рот», «бот», но не «кот»

Квантификаторы — счётчики повторений

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

Квантификатор Значение Пример
* 0 или больше раз ко*т найдёт «кт», «кот», «коот», «кооот»
+ 1 или больше раз ко+т найдёт «кот», «коот», но не «кт»
? 0 или 1 раз ко?т найдёт «кт» и «кот»
{n} Ровно n раз ко{2}т найдёт только «коот»
{n,m} От n до m раз ко{1,3}т найдёт «кот», «коот», «кооот»

Якоря — привязка к позиции

Якоря не ищут символы, они указывают на позицию в строке:

  • ^ — начало строки
  • $ — конец строки
  •  — граница слова

Например, ^Привет найдёт слово «Привет» только если оно стоит в самом начале строки. А мир$ — только если слово «мир» стоит в конце.

Синтаксис в Rust: все формы и варианты

В Rust работа с регулярными выражениями осуществляется через крейт regex. Это не встроенная возможность языка, а внешняя библиотека, которую нужно добавить в проект. Почему так? Потому что Rust стремится к минимализму: в ядре языка только то, что нужно для работы с памятью и производительностью. Всё остальное — библиотеки.

Установка и подключение

Чтобы использовать регулярные выражения в Rust, добавь в файл Cargo.toml:

[dependencies]
regex = "1"

А в коде подключи так:

// Подключаем крейт regex в наш проект
// use — это ключевое слово для импорта
use regex::Regex;

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

У структуры Regex есть несколько ключевых методов. Вот они в таблице:

Метод Что делает Возвращает
Regex::new(r"...") Создаёт новое регулярное выражение Result<Regex, Error>
is_match(text) Проверяет, есть ли хотя бы одно совпадение bool
find(text) Находит первое совпадение Option<Match>
find_iter(text) Находит все совпадения (возвращает итератор) Matches
captures(text) Находит совпадения с захватом групп Option<Captures>
replace(text, replacement) Заменяет первое совпадение String
replace_all(text, replacement) Заменяет все совпадения String
Обрати внимание на букву r перед строкой: r"..." — это «сырая» строка (raw string). В ней не нужно экранировать обратные слеши. Без неё пришлось бы писать \d вместо d, что очень неудобно. Всегда используй сырые строки для регулярных выражений в Rust!

Группы захвата

Группы захвата — это способ «вытащить» часть найденного текста. Они обозначаются круглыми скобками (). Например, если мы ищем дату в формате «день-месяц-год», мы можем захватить каждую часть отдельно:

// Создаём регулярное выражение с тремя группами захвата
// (d{2}) — захватываем ровно две цифры (день)
// (d{2}) — захватываем ровно две цифры (месяц)
// (d{4}) — захватываем ровно четыре цифры (год)
let re = Regex::new(r"(d{2})-(d{2})-(d{4})").unwrap();

// Текст, в котором будем искать
let text = "Сегодня 15-03-2024, а завтра будет 16-03-2024";

// Находим первое совпадение с захватом групп
if let Some(caps) = re.captures(text) {
    // caps.get(0) — это всё найденное выражение целиком
    // caps.get(1) — первая группа (день)
    // caps.get(2) — вторая группа (месяц)
    // caps.get(3) — третья группа (год)
    println!("Найдена дата: {}", &caps[0]);  // Выведет: 15-03-2024
    println!("День: {}", &caps[1]);          // Выведет: 15
    println!("Месяц: {}", &caps[2]);         // Выведет: 03
    println!("Год: {}", &caps[3]);           // Выведет: 2024
}

Примеры кода — от простого к сложному

Пример 1: Проверка, содержит ли строка цифры

// Самый простой пример: проверяем, есть ли в строке хотя бы одна цифра
use regex::Regex;

fn main() {
    // Создаём регулярное выражение: d — любая цифра
    // unwrap() — говорим Rust: "я уверен, что выражение корректно, дай мне его"
    let re = Regex::new(r"d").unwrap();
    
    let test_string = "Привет, мне 25 лет";
    
    // is_match возвращает true или false
    if re.is_match(test_string) {
        println!("В строке есть цифры!");
    } else {
        println!("Цифр нет.");
    }
    
    // А в этой строке цифр нет
    let test_string2 = "Привет, мир!";
    println!("Есть цифры? {}", re.is_match(test_string2)); // Выведет: false
}

Пример 2: Поиск всех email-адресов в тексте

use regex::Regex;

fn main() {
    // Создаём шаблон для поиска email-адресов
    // [w.]+ — один или больше символов: буквы, цифры, подчёркивание или точка
    // @ — символ собаки
    // [w.]+ — домен (например, gmail.com)
    // . — экранированная точка (просто точка, а не "любой символ")
    // w+ — доменная зона (com, ru, org)
    let email_re = Regex::new(r"[w.]+@[w.]+.w+").unwrap();
    
    let text = "Мои контакты: ivan@example.com и petr@mail.ru. 
                Также можно писать на support@my-site.org";
    
    // find_iter возвращает итератор по всем совпадениям
    for email in email_re.find_iter(text) {
        // email.as_str() — получаем текст совпадения
        println!("Найден email: {}", email.as_str());
    }
    // Выведет:
    // Найден email: ivan@example.com
    // Найден email: petr@mail.ru
    // Найден email: support@my-site.org
}

Пример 3: Извлечение чисел из строки

use regex::Regex;

fn main() {
    // Ищем все числа: одна или больше цифр подряд
    let re = Regex::new(r"d+").unwrap();
    
    let text = "В 2024 году было 365 дней, 52 недели и 12 месяцев";
    
    println!("Найденные числа:");
    for number in re.find_iter(text) {
        // parse::() — превращаем строку в число
        // Это может не сработать, если число слишком большое
        let num: i32 = number.as_str().parse().unwrap();
        println!("  {}", num);
    }
    // Выведет:
    // 2024
    // 365
    // 52
    // 12
}

Пример 4: Проверка корректности номера телефона

use regex::Regex;

fn main() {
    // Шаблон для российского номера телефона:
    // ^ — начало строки
    // +7 — символы +7
    // d{3} — ровно 3 цифры (код оператора)
    // d{3} — ещё 3 цифры
    // d{2} — 2 цифры
    // d{2} — последние 2 цифры
    // $ — конец строки
    let phone_re = Regex::new(r"^+d{11}$").unwrap();
    
    let phones = vec![
        "+79261234567",  // правильный
        "89261234567",   // без плюса
        "+7926123456",   // короткий
        "+7abc1234567",  // буквы вместо цифр
    ];
    
    for phone in phones {
        if phone_re.is_match(phone) {
            println!("{} — корректный номер", phone);
        } else {
            println!("{} — НЕ корректный номер", phone);
        }
    }
    // Выведет:
    // +79261234567 — корректный номер
    // 89261234567 — НЕ корректный номер
    // +7926123456 — НЕ корректный номер
    // +7abc1234567 — НЕ корректный номер
}

Пример 5: Замена текста по шаблону

use regex::Regex;

fn main() {
    // Заменяем все вхождения слова "плохой" на "хороший"
    let re = Regex::new(r"плохой").unwrap();
    
    let text = "Это был плохой день. Плохой фильм и плохая погода.";
    
    // replace_all заменяет все совпадения
    let result = re.replace_all(text, "хороший");
    println!("Было: {}", text);
    println!("Стало: {}", result);
    // Выведет:
    // Было: Это был плохой день. Плохой фильм и плохая погода.
    // Стало: Это был хороший день. Плохой фильм и плохая погода.
    
    // Обрати внимание: "Плохой" с большой буквы не заменился!
    // Регулярные выражения по умолчанию чувствительны к регистру
}

Пример 6: Замена с учётом регистра и групп

use regex::RegexBuilder;

fn main() {
    // RegexBuilder позволяет настроить флаги
    // (?i) — флаг, отключающий чувствительность к регистру
    let re = RegexBuilder::new(r"(?i)плохw+")
        .build()
        .unwrap();
    
    let text = "Это был ПЛОХОЙ день. плохой фильм и Плохая погода.";
    
    // Заменяем все варианты слова "плохой" на "отличный"
    let result = re.replace_all(text, "отличный");
    println!("Было: {}", text);
    println!("Стало: {}", result);
    // Выведет:
    // Было: Это был ПЛОХОЙ день. плохой фильм и Плохая погода.
    // Стало: Это был отличный день. отличный фильм и отличная погода.
}

Пример 7: Разбор сложной строки с группами захвата

use regex::Regex;

fn main() {
    // Разбираем строку с информацией о человеке
    // Формат: "Имя: Иван, Возраст: 25, Город: Москва"
    let re = Regex::new(
        r"Имя:s*(w+),s*Возраст:s*(d+),s*Город:s*(w+)"
    ).unwrap();
    
    let text = "Имя: Иван, Возраст: 25, Город: Москва";
    
    if let Some(caps) = re.captures(text) {
        println!("Полная строка: {}", &caps[0]);  // Всё совпадение
        println!("Имя: {}", &caps[1]);            // Первая группа
        println!("Возраст: {}", &caps[2]);        // Вторая группа
        println!("Город: {}", &caps[3]);          // Третья группа
        
        // Можем преобразовать возраст в число
        let age: i32 = caps[2].parse().unwrap();
        println!("Через 10 лет будет: {}", age + 10);
    }
    // Выведет:
    // Полная строка: Имя: Иван, Возраст: 25, Город: Москва
    // Имя: Иван
    // Возраст: 25
    // Город: Москва
    // Через 10 лет будет: 35
}

Пример 8: Поиск повторяющихся слов

use regex::Regex;

fn main() {
    // Ищем повторяющиеся слова, идущие подряд
    // (w+) — захватываем слово
    // s+ — один или больше пробелов
    // 1 — обратная ссылка на первую группу (то же самое слово)
    let re = Regex::new(r"(w+)s+1").unwrap();
    
    let text = "Это это очень странный текст с с повторяющимися словами словами";
    
    println!("Найденные повторы:");
    for match_found in re.find_iter(text) {
        println!("  '{}'", match_found.as_str());
    }
    // Выведет:
    // 'это это'
    // 'с с'
    // 'словами словами'
}

Сравнение с Python: чем похоже и чем отличается

Если ты уже знаком с Python, тебе будет проще освоить регулярные выражения в Rust. Но есть важные отличия:

Характеристика Python Rust
Подключение Встроенный модуль re Внешний крейт regex
Сырые строки r"..." — опционально r"..." — настоятельно рекомендуется
Обработка ошибок Выбрасывает исключение Возвращает Result
Производительность Хорошая Отличная (компилируется в машинный код)
Группы захвата group(1) &caps[1]
Замена re.sub(pattern, repl, text) re.replace(text, repl)
Флаги Флаги как аргументы: re.IGNORECASE Флаги в шаблоне: (?i) или через RegexBuilder

Вот как выглядит один и тот же пример на двух языках:

// Rust
use regex::Regex;

fn main() {
    let re = Regex::new(r"d+").unwrap();
    let text = "Возраст: 25 лет";
    if let Some(m) = re.find(text) {
        println!("Найдено: {}", m.as_str());
    }
}
# Python
import re

text = "Возраст: 25 лет"
match = re.search(r"d+", text)
if match:
    print(f"Найдено: {match.group()}")
Главное отличие: В Rust всё явное. Ты всегда видишь, где может возникнуть ошибка (unwrap), где может не быть совпадения (Option). В Python многое скрыто «под капотом». Rust даёт больше контроля, но требует больше кода.

Типичные ошибки и подводные камни

Ошибка 1: Забыл про экранирование специальных символов

Символы ., *, +, ?, (, ), [, ], {, }, ^, $, |, имеют специальное значение. Если нужно найти их как обычные символы — поставь перед ними обратный слеш.

// Неправильно: точка означает "любой символ"
let wrong = Regex::new(r"к.т").unwrap();  // Найдёт "кот", "кит", "к_т"

// Правильно: экранированная точка означает просто точку
let correct = Regex::new(r"к.т").unwrap();  // Найдёт только "к.т"

Ошибка 2: Жадность квантификаторов

По умолчанию квантификаторы * и + «жадные» — они захватывают максимально возможную строку. Чтобы сделать их «ленивыми», добавь ? после квантификатора.

let text = "<b>жирный</b> и <b>тоже жирный</b>";

// Жадный режим: захватит от первого < до последнего >
let greedy = Regex::new(r"<.+>").unwrap();
println!("Жадный: {}", greedy.find(text).unwrap().as_str());
// Выведет: <b>жирный</b> и <b>тоже жирный</b>

// Ленивый режим: захватит минимально возможное
let lazy = Regex::new(r"<.+?>").unwrap();
println!("Ленивый: {}", lazy.find(text).unwrap().as_str());
// Выведет: <b>

Ошибка 3: Использование unwrap без проверки

unwrap() — это как сказать Rust: «Я уверен, что здесь нет ошибки, дай мне значение». Если ошибка всё же есть — программа упадёт. Всегда проверяй регулярное выражение на валидность:

// Плохо: если выражение некорректно, программа упадёт
let re = Regex::new(r"[незакрытый класс").unwrap();

// Хорошо: обрабатываем ошибку
match Regex::new(r"[незакрытый класс") {
    Ok(re) => println!("Всё хорошо, используем: {:?}", re),
    Err(e) => println!("Ошибка в регулярном выражении: {}", e),
}

Ошибка 4: Забыл про флаг (?i) для регистронезависимости

По умолчанию регулярные выражения чувствительны к регистру. Если нужно искать без учёта регистра, используй флаг (?i) в начале шаблона или RegexBuilder.

Ошибка 5: Слишком сложные выражения

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

Лучшие практики и советы профессионалов

1. Компилируй регулярные выражения один раз

Создание регулярного выражения — дорогая операция. Если ты используешь одно и то же выражение много раз (например, в цикле), создай его один раз и переиспользуй:

// Плохо: создаём новое выражение на каждой итерации
for text in texts {
    let re = Regex::new(r"d+").unwrap();
    if re.is_match(text) { ... }
}

// Хорошо: создаём один раз до цикла
let re = Regex::new(r"d+").unwrap();
for text in texts {
    if re.is_match(text) { ... }
}

2. Используй lazy_static для глобальных выражений

Если регулярное выражение используется во многих местах программы, создай его как глобальную константу с помощью крейта lazy_static или once_cell:

// Добавь в Cargo.toml: once_cell = "1"
use once_cell::sync::Lazy;
use regex::Regex;

// Глобальное регулярное выражение, которое компилируется один раз
static EMAIL_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"[w.]+@[w.]+.w+").unwrap()
});

fn main() {
    let text = "test@example.com";
    // Используем как обычную переменную
    if EMAIL_RE.is_match(text) {
        println!("Это email!");
    }
}

3. Тестируй регулярные выражения отдельно

Регулярные выражения сложно отлаживать. Используй онлайн-тестеры (например, regex101.com) для проверки шаблона перед тем, как вставлять его в код. В Rust также можно написать тесты:

#[cfg(test)]
mod tests {
    use super::*;
    use regex::Regex;

    #[test]
    fn test_email_pattern() {
        let re = Regex::new(r"[w.]+@[w.]+.w+").unwrap();
        assert!(re.is_match("user@example.com"));
        assert!(re.is_match("test@mail.ru"));
        assert!(!re.is_match("not_an_email"));
        assert!(!re.is_match("@example.com"));
    }
}

4. Избегай катастрофического возврата (catastrophic backtracking)

Некоторые шаблоны могут работать очень медленно на определённых входных данных. Например, (a+)+b на строке из множества символов «a» без «b» в конце заставит движок перебирать миллионы комбинаций. Если замечаешь, что программа «зависает» на больших текстах — проверь регулярные выражения.

5. Документируй сложные выражения

Регулярные выражения — это «write-only» код: написать легко, прочитать — сложно. Всегда комментируй, что делает каждое выражение:

// Шаблон для поиска дат в формате ДД.ММ.ГГГГ или ДД-ММ-ГГГГ
// (d{2})   — день (2 цифры)
// [.-/]    — разделитель (точка, дефис или слеш)
// (d{2})   — месяц (2 цифры)
// [.-/]    — разделитель
// (d{4})   — год (4 цифры)
let date_re = Regex::new(r"(d{2})[.-/](d{2})[.-/](d{4})").unwrap();

Вопросы для самопроверки

  1. Что такое регулярное выражение и для чего оно используется?

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

  2. Какой символ в регулярных выражениях означает «любая цифра»?

    Ответ: d. Например, dd