⚙️ C++

Регулярные выражения в C++: подробное руководство

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

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

В мире программирования регулярные выражения (или regex от английского regular expressions) — это мощнейший инструмент для поиска и обработки текста. Они позволяют задать не просто конкретное слово, а шаблон (pattern), которому может соответствовать множество разных строк.

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

В C++ регулярные выражения появились в стандарте C++11 и с тех пор стали незаменимым инструментом для:

  • Валидации данных — проверка, правильно ли введён email, номер телефона или почтовый индекс
  • Поиска и замены — найти все даты в тексте и изменить их формат
  • Разбора логов — извлечь нужную информацию из тысяч строк системных записей
  • Лексического анализа — разбить программный код на составные части

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

Чтобы понять, как работают регулярные выражения, нужно представить себе конечный автомат. Не пугайтесь этого термина — на самом деле всё просто.

Представьте, что у вас есть карта с городами и дорогами между ними. Вы начинаете путь в городе «Старт» и двигаетесь по дорогам, читая буквы текста. Каждая буква — это указатель, по какой дороге идти дальше. Если после прочтения всей строки вы оказались в городе «Финиш» — значит, строка подходит под шаблон. Если вы заблудились или не дошли до финиша — строка не соответствует.

Регулярное выражение компилируется в такой автомат внутри библиотеки C++. Когда вы передаёте строку для проверки, движок regex проходит по ней символ за символом, пытаясь проложить путь от начала до конца в соответствии с вашим шаблоном.

Основные строительные блоки регулярных выражений:

Символ Значение Пример Пояснение
. Любой одиночный символ к.т Найдёт "кот", "кит", "кут"
* 0 или более повторений ко*т Найдёт "кт", "кот", "коот", "кооот"
+ 1 или более повторений ко+т Найдёт "кот", "коот", но не "кт"
? 0 или 1 повторение ко?т Найдёт "кт" и "кот"
[] Набор символов [км]от Найдёт "кот" и "мот"
[^] Отрицание набора [^к]от Найдёт "рот", "бот", но не "кот"
^ Начало строки ^кот Найдёт "кот" только в начале строки
$ Конец строки кот$ Найдёт "кот" только в конце строки
| Или (альтернатива) кот|пёс Найдёт либо "кот", либо "пёс"
() Группировка (ко)+т Найдёт "кот", "кокот", "кококот"
d Цифра (0-9) ddd Найдёт любые три цифры подряд
w Буква, цифра или подчёркивание w+ Найдёт целое слово
s Пробельный символ s+ Найдёт один или несколько пробелов
Важно: В C++ строки с регулярными выражениями нужно писать с двойным экранированием. Например, d в регулярном выражении превращается в "\d" в коде C++. Это связано с тем, что обратная косая черта имеет особое значение в строках C++.

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

В C++ работа с регулярными выражениями сосредоточена в заголовочном файле <regex>. Основные классы и функции:

Класс/Функция Назначение Пример использования
std::regex Представляет само регулярное выражение std::regex pattern("\d+");
std::regex_match() Проверяет, соответствует ли вся строка шаблону regex_match("123", pattern)
std::regex_search() Ищет первое совпадение в строке regex_search("abc123def", pattern)
std::regex_replace() Заменяет совпадения на новый текст regex_replace("abc123", pattern, "X")
std::smatch Хранит результаты поиска (для строк) std::smatch matches;
std::sregex_iterator Итератор для перебора всех совпадений std::sregex_iterator it(...)

Флаги компиляции регулярных выражений:

При создании объекта std::regex можно указать дополнительные флаги, которые меняют поведение поиска:

Флаг Описание
std::regex::icase Игнорировать регистр букв
std::regex::ECMAScript Использовать синтаксис ECMAScript (по умолчанию)
std::regex::basic Базовый синтаксис POSIX
std::regex::extended Расширенный синтаксис POSIX
std::regex::awk Синтаксис утилиты awk
std::regex::grep Синтаксис утилиты grep
std::regex::egrep Расширенный синтаксис grep
Совет: Для большинства задач используйте синтаксис ECMAScript (он используется по умолчанию). Он наиболее близок к тому, что вы видели в JavaScript или Python.

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

Пример 1: Проверка, является ли строка числом

#include <iostream>
#include <regex>
#include <string>

int main() {
    // Создаём регулярное выражение для поиска целого числа
    // \d+ означает "одна или более цифр"
    std::regex numberPattern("\d+");
    
    std::string test1 = "12345";
    std::string test2 = "abc123";
    std::string test3 = "12.34";
    
    // regex_match проверяет, соответствует ли ВСЯ строка шаблону
    std::cout << "Тест 1 (12345): " 
              << (std::regex_match(test1, numberPattern) ? "Да" : "Нет") 
              << std::endl;
    
    std::cout << "Тест 2 (abc123): " 
              << (std::regex_match(test2, numberPattern) ? "Да" : "Нет") 
              << std::endl;
    
    std::cout << "Тест 3 (12.34): " 
              << (std::regex_match(test3, numberPattern) ? "Да" : "Нет") 
              << std::endl;
    
    return 0;
}

Объяснение: Мы создали шаблон \d+, который ищет последовательность из одной или нескольких цифр. Функция regex_match проверяет, полностью ли строка соответствует этому шаблону. Строка "12345" подходит, а "abc123" — нет, потому что в ней есть буквы.

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

#include <iostream>
#include <regex>
#include <string>

int main() {
    // Шаблон для поиска email-адреса
    // \w+ — одно или более буквенно-цифровых символов
    // @ — символ собаки
    // [a-zA-Z]+\. — домен первого уровня (например, gmail.)
    // [a-zA-Z]{2,} — домен второго уровня (например, com, ru)
    std::regex emailPattern("\w+@[a-zA-Z]+\.\w{2,}");
    
    std::string text = "Мои контакты: ivan@mail.com и petr@yandex.ru";
    
    // Создаём объект для хранения результатов поиска
    std::smatch matches;
    
    // Ищем первое совпадение
    if (std::regex_search(text, matches, emailPattern)) {
        std::cout << "Найден email: " << matches[0] << std::endl;
        std::cout << "Позиция в тексте: " << matches.position() << std::endl;
    }
    
    // Найдём все email-адреса с помощью итератора
    std::cout << "
Все найденные email-адреса:" << std::endl;
    auto words_begin = std::sregex_iterator(text.begin(), text.end(), emailPattern);
    auto words_end = std::sregex_iterator();
    
    for (std::sregex_iterator i = words_begin; i != words_end; ++i) {
        std::smatch match = *i;
        std::cout << match.str() << std::endl;
    }
    
    return 0;
}

Объяснение: Этот пример показывает два способа поиска. regex_search находит первое совпадение, а sregex_iterator позволяет перебрать все совпадения в тексте. Обратите внимание на экранирование точки — \. означает именно символ точки, а не любой символ.

Пример 3: Замена всех дат в тексте

#include <iostream>
#include <regex>
#include <string>

int main() {
    // Исходный текст с датами в формате ДД.ММ.ГГГГ
    std::string text = "Сегодня 15.03.2024. Встреча назначена на 20.04.2024. "
                       "Дедлайн: 01.05.2024.";
    
    std::cout << "Исходный текст: " << text << std::endl;
    
    // Шаблон для поиска дат: две цифры, точка, две цифры, точка, четыре цифры
    std::regex datePattern("(\d{2})\.(\d{2})\.(\d{4})");
    
    // Заменяем на формат ГГГГ-ММ-ДД
    // $1, $2, $3 — ссылки на группы захвата
    std::string newText = std::regex_replace(text, datePattern, "$3-$2-$1");
    
    std::cout << "После замены: " << newText << std::endl;
    
    return 0;
}

Объяснение: Здесь мы используем группы захвата (). Каждая группа запоминает свою часть совпадения. В строке замены $1, $2, $3 ссылаются на эти группы. Мы меняем порядок: год становится первым, месяц — вторым, день — третьим.

Пример 4: Проверка сложности пароля

#include <iostream>
#include <regex>
#include <string>

int main() {
    std::string password;
    std::cout << "Введите пароль: ";
    std::getline(std::cin, password);
    
    // Проверяем различные требования к паролю
    // Хотя бы одна заглавная буква
    std::regex hasUpper("[A-Z]");
    // Хотя бы одна строчная буква
    std::regex hasLower("[a-z]");
    // Хотя бы одна цифра
    std::regex hasDigit("\d");
    // Хотя бы один спецсимвол
    std::regex hasSpecial("[!@#$%^&*()]");
    // Длина не менее 8 символов
    std::regex hasMinLength(".{8,}");
    
    int score = 0;
    
    if (std::regex_search(password, hasUpper)) score++;
    if (std::regex_search(password, hasLower)) score++;
    if (std::regex_search(password, hasDigit)) score++;
    if (std::regex_search(password, hasSpecial)) score++;
    if (std::regex_search(password, hasMinLength)) score++;
    
    std::cout << "Сложность пароля: " << score << "/5" << std::endl;
    
    if (score < 3) {
        std::cout << "Пароль слишком слабый!" << std::endl;
    } else if (score < 5) {
        std::cout << "Пароль средний, можно улучшить." << std::endl;
    } else {
        std::cout << "Отличный пароль!" << std::endl;
    }
    
    return 0;
}

Объяснение: Вместо одного сложного регулярного выражения мы используем несколько простых. Каждое проверяет одно конкретное требование. Это делает код более читаемым и понятным.

Пример 5: Извлечение чисел из текста

#include <iostream>
#include <regex>
#include <string>
#include <vector>

int main() {
    std::string text = "В магазине 5 яблок, 12 груш и 3 апельсина. "
                       "Цена: 99.99 рублей за кг.";
    
    // Шаблон для поиска чисел (целых и с плавающей точкой)
    // \d+ — одна или более цифр
    // (?:\.\d+)? — необязательная дробная часть
    std::regex numberPattern("\d+(?:\.\d+)?");
    
    std::vector<std::string> numbers;
    
    // Собираем все числа в вектор
    auto begin = std::sregex_iterator(text.begin(), text.end(), numberPattern);
    auto end = std::sregex_iterator();
    
    for (auto it = begin; it != end; ++it) {
        numbers.push_back(it->str());
    }
    
    std::cout << "Найденные числа:" << std::endl;
    for (const auto& num : numbers) {
        std::cout << num << std::endl;
    }
    
    // Преобразуем строки в числа и суммируем
    double sum = 0.0;
    for (const auto& num : numbers) {
        sum += std::stod(num);
    }
    
    std::cout << "Сумма всех чисел: " << sum << std::endl;
    
    return 0;
}

Объяснение: Шаблон \d+(?:\.\d+)? использует не захватывающую группу (?:...) для дробной части. Знак вопроса делает эту группу необязательной. Таким образом, мы находим и целые числа (5, 12, 3), и числа с плавающей точкой (99.99).

Пример 6: Разбор URL на составные части

#include <iostream>
#include <regex>
#include <string>

int main() {
    std::string url = "https://www.example.com:8080/path/to/page?name=value&key=val#section";
    
    // Сложное регулярное выражение для разбора URL
    // Протокол: http:// или https://
    // Домен: буквы, цифры, точки, дефисы
    // Порт: необязательный, :цифры
    // Путь: необязательный, /что-то
    // Параметры: необязательные, ?ключ=значение
    // Якорь: необязательный, #что-то
    std::regex urlPattern(
        "(https?://)?"           // Протокол (группа 1)
        "([\w.-]+)"             // Домен (группа 2)
        "(?::(\d+))?"           // Порт (группа 3)
        "(/[\w/]*)?"            // Путь (группа 4)
        "(?:\?([\w=&]+))?"     // Параметры (группа 5)
        "(?:#(\w+))?"           // Якорь (группа 6)
    );
    
    std::smatch matches;
    
    if (std::regex_match(url, matches, urlPattern)) {
        std::cout << "Протокол: " << matches[1] << std::endl;
        std::cout << "Домен: " << matches[2] << std::endl;
        std::cout << "Порт: " << matches[3] << std::endl;
        std::cout << "Путь: " << matches[4] << std::endl;
        std::cout << "Параметры: " << matches[5] << std::endl;
        std::cout << "Якорь: " << matches[6] << std::endl;
    }
    
    return 0;
}

Объяснение: Этот пример показывает, как разбить сложную строку на логические части с помощью групп захвата. Каждая часть URL помещается в свою группу, и мы можем обратиться к ней по индексу. Необязательные части помечены знаком вопроса.

Пример 7: Валидация телефонного номера

#include <iostream>
#include <regex>
#include <string>

int main() {
    // Различные форматы телефонных номеров
    std::vector<std::string> phones = {
        "+7 (123) 456-78-90",
        "8-123-456-78-90",
        "1234567890",
        "+1 234 567 8900",
        "abc-def-ghij"  // Неправильный номер
    };
    
    // Шаблон для российских номеров
    // Варианты: +7, 8, или просто 10 цифр
    // Разделители: пробелы, дефисы, скобки — необязательны
    std::regex phonePattern(
        "^(?:\+7|8)?"           // Код страны: +7 или 8 (необязательно)
        "[-\s]?"                // Необязательный разделитель
        "\(?(\d{3})\)?"       // Код города: 3 цифры, могут быть в скобках
        "[-\s]?"                // Разделитель
        "(\d{3})"               // Первые 3 цифры номера
        "[-\s]?"                // Разделитель
        "(\d{2})"               // Следующие 2 цифры
        "[-\s]?"                // Разделитель
        "(\d{2})$"              // Последние 2 цифры
    );
    
    for (const auto& phone : phones) {
        std::smatch matches;
        bool isValid = std::regex_match(phone, matches, phonePattern);
        
        std::cout << phone << " — " 
                  << (isValid ? "корректный" : "некорректный");
        
        if (isValid) {
            std::cout << " (код: " << matches[1] 
                      << ", номер: " << matches[2] 
                      << matches[3] << matches[4] << ")";
        }
        std::cout << std::endl;
    }
    
    return 0;
}

Объяснение: Этот пример демонстрирует, как обрабатывать разные форматы одних и тех же данных. Шаблон учитывает различные варианты записи номера: с +7, с 8, с пробелами, дефисами и скобками. Группы захвата позволяют извлечь код города и сам номер отдельно.

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

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

Аспект C++ Python
Подключение #include <regex> import re
Создание шаблона std::regex pattern("\d+"); pattern = re.compile(r"d+")
Экранирование Двойное: \d Сырые строки: r"d"
Поиск совпадения std::regex_match() re.fullmatch()
Поиск в строке std::regex_search() re.search()
Все совпадения std::sregex_iterator re.findall()
Замена std::regex_replace() re.sub()
Игнорирование регистра std::regex::icase re.IGNORECASE
Производительность Выше (компилируемый язык) Ниже (интерпретируемый)
Удобство использования Многословнее, сложнее Проще, лаконичнее
Главное отличие: В C++ нет «сырых» строк (raw strings), поэтому все обратные слеши нужно удваивать. Это делает регулярные выражения в C++ более громоздкими. Однако C++ выигрывает в производительности, что критично для обработки больших объёмов данных.

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

1. Забыли про двойное экранирование

Самая частая ошибка новичков — написать "d" вместо "\d". В C++ обратная косая черта в строке — это escape-символ. "d" будет интерпретироваться как символ 'd' с каким-то специальным значением (которого нет), а не как «цифра».

2. Путаница между regex_match и regex_search

regex_match проверяет, соответствует ли вся строка шаблону от начала до конца. regex_search ищет подстроку, соответствующую шаблону. Если вам нужно найти слово в тексте — используйте regex_search.

3. Жадность квантификаторов

По умолчанию * и + являются «жадными» — они захватывают максимально возможную строку. Например, для текста "<div>текст</div>" шаблон <.*> найдёт всю строку целиком, а не отдельные теги. Чтобы сделать квантификатор «ленивым», добавьте после него знак вопроса: <.*?>.

4. Проблемы с кодировкой

Стандартная библиотека <regex> в C++ плохо работает с Unicode и кириллицей. Если вам нужно обрабатывать русские буквы, используйте std::wstring и std::wregex.

5. Исключения при компиляции

Если ваше регулярное выражение содержит синтаксическую ошибку, при создании объекта std::regex будет выброшено исключение std::regex_error. Всегда оборачивайте создание regex в try-catch блок.

try {
    std::regex pattern("[неправильный шаблон");
} catch (const std::regex_error& e) {
    std::cerr << "Ошибка в регулярном выражении: " << e.what() << std::endl;
}

6. Производительность

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

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

1. Начинайте с простого

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

2. Комментируйте сложные выражения

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

3. Используйте именованные классы символов

Вместо [0-9] используйте \d, вместо [a-zA-Z0-9_] — \w. Это делает выражения короче и понятнее.

4. Предпочитайте конкретные символы общим

Если вы ищете конкретный символ (например, точку), используйте \. вместо .. Точка в регулярных выражениях означает «любой символ», что может привести к неожиданным совпадениям.

5. Проверяйте граничные случаи

Всегда тестируйте свои регулярные выражения на пустых строках, строках с пробелами, строках со спецсимволами. То, что работает для «нормальных» данных, может сломаться на граничных случаях.

6. Не злоупотребляйте регулярными выражениями

Не всё нужно решать с помощью regex. Для простого поиска подстроки используйте