☕ Java

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

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

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

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

  • Находить в тексте фрагменты по сложным условиям
  • Проверять, соответствует ли строка определённому формату (например, email или номер телефона)
  • Заменять одни части текста на другие
  • Разбивать текст на части по заданным разделителям
💡 Простая аналогия: Регулярное выражение — это как маска для поиска файлов в проводнике. Если ты напишешь *.txt, компьютер найдёт все текстовые файлы. Регулярные выражения — это та же идея, но в сотни раз мощнее и гибче.

В Java регулярные выражения появились начиная с версии 1.4 (2002 год) и с тех пор стали незаменимым инструментом любого разработчика. Они используются везде: от проверки паролей в приложениях до парсинга сложных логов серверов.

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

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

Шаблон — это программа для поиска

Когда ты пишешь регулярное выражение, ты фактически создаёшь мини-программу. Эта программа говорит: «Пройди по тексту символ за символом и проверь, совпадает ли текущая позиция с моими правилами». Java берёт твой шаблон и компилирует его в специальную внутреннюю структуру данных — конечный автомат.

Конечный автомат — сердце regex

Представь себе робота, который стоит на рельсах (строка текста). У робота есть несколько состояний:

  • Состояние 0: «Я ещё ничего не нашёл, ищу начало совпадения»
  • Состояние 1: «Я нашёл первую букву "к", проверяю дальше»
  • Состояние 2: «Я нашёл "ко", продолжаю»
  • Состояние 3 (финальное): «Я нашёл "кот"! Записываю результат и ищу следующее»

Робот двигается по тексту, переключая состояния в зависимости от того, какие символы встречает. Если он доходит до финального состояния — совпадение найдено. Если встречает символ, который не подходит ни под одно правило, — он возвращается назад и пробует другой путь. Этот процесс называется backtracking (возврат).

⚠️ Важно: Механизм возврата — самая ресурсоёмкая часть регулярных выражений. Плохо написанный шаблон может заставить компьютер перебирать миллионы комбинаций, что приведёт к зависанию программы. Это называется «катастрофический возврат» (catastrophic backtracking).

Движок регулярных выражений в Java

Java использует NFA (Nondeterministic Finite Automaton) — недетерминированный конечный автомат. Это значит, что в одной и той же позиции может быть несколько вариантов продолжения, и движок пробует их по очереди. Альтернатива — DFA (детерминированный автомат), который работает быстрее, но менее гибкий. Java выбрала NFA, потому что он поддерживает больше возможностей: обратные ссылки, lookahead и lookbehind.

Основные компоненты любого regex

Компонент Описание Пример
Литералы Обычные символы, которые ищут сами себя cat найдёт строку "cat"
Метасимволы Символы со специальным значением . — любой символ, d — цифра
Квантификаторы Указывают, сколько раз повторяется символ * — 0 или более, + — 1 или более
Группы Объединяют часть шаблона в одно целое (ab)+ — "ab", "abab", "ababab"
Якоря Привязывают поиск к началу/концу строки ^ — начало, $ — конец

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

В Java работа с регулярными выражениями сосредоточена в двух классах: java.util.regex.Pattern и java.util.regex.Matcher. Есть также удобные методы в классе String.

Класс Pattern — фабрика шаблонов

Этот класс нельзя создать через new. Вместо этого используется статический метод compile():

// Компилируем шаблон
Pattern pattern = Pattern.compile("\d+"); // Шаблон: одна или более цифр

// Можно добавить флаги (опции)
Pattern patternWithFlags = Pattern.compile("[a-z]+", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);

Класс Matcher — исполнитель

Matcher создаётся из Pattern и применяется к конкретной строке:

Pattern pattern = Pattern.compile("\d+");
Matcher matcher = pattern.matcher("Год 2024, месяц 12");

// Основные методы Matcher:
// find() — ищет следующее совпадение (возвращает boolean)
// matches() — проверяет, совпадает ли ВСЯ строка с шаблоном
// group() — возвращает найденное совпадение
// start() / end() — позиции начала и конца совпадения

while (matcher.find()) {
    System.out.println("Найдено: " + matcher.group());
    System.out.println("Позиция: " + matcher.start() + "-" + matcher.end());
}
// Вывод:
// Найдено: 2024
// Позиция: 4-8
// Найдено: 12
// Позиция: 15-17

Методы класса String

Для простых задач не нужно создавать Pattern и Matcher — у String есть встроенные методы:

Метод Описание Пример
matches(regex) Проверяет, совпадает ли вся строка с regex "123".matches("\d+") → true
replaceAll(regex, replacement) Заменяет все совпадения на replacement "a1b2c3".replaceAll("\d", "*") → "a*b*c*"
replaceFirst(regex, replacement) Заменяет только первое совпадение "a1b2c3".replaceFirst("\d", "*") → "a*b2c3"
split(regex) Разбивает строку по разделителю "a,b,c".split(",") → ["a", "b", "c"]

Справочник метасимволов

Символ Значение Пример
. Любой символ (кроме перевода строки) c.t найдёт "cat", "cot", "c3t"
d Цифра [0-9] d{3} — три цифры подряд
D Не цифра D+ — один или более не-цифр
w Буква, цифра или подчёркивание [a-zA-Z0-9_] w+ — слово
W Всё, кроме букв, цифр и подчёркивания W — знак пунктуации
s Пробельный символ (пробел, табуляция, новая строка) s+ — один или более пробелов
S Непробельный символ S{3} — три непробельных символа
^ Начало строки ^Привет — строка начинается с "Привет"
$ Конец строки конец$ — строка заканчивается на "конец"
 Граница слова кот — слово "кот", но не "котёнок"

Квантификаторы — указатели количества

Квантификатор Значение Пример
* 0 или более раз ab* — "a", "ab", "abb", "abbb"...
+ 1 или более раз ab+ — "ab", "abb", но не "a"
? 0 или 1 раз (необязательно) colou?r — "color" и "colour"
{n} Ровно n раз d{3} — ровно три цифры
{n,} n или более раз d{2,} — две или более цифр
{n,m} От n до m раз d{2,4} — от двух до четырёх цифр

Группы и обратные ссылки

Круглые скобки () создают группы. Группы нумеруются с 1. Можно ссылаться на группу внутри самого regex:

// Поиск повторяющихся слов
Pattern pattern = Pattern.compile("\b(\w+)\s+\1\b");
Matcher matcher = pattern.matcher("кот кот собака");
// \1 — ссылка на первую группу, ищет то же самое слово

while (matcher.find()) {
    System.out.println("Повтор: " + matcher.group());
}
// Вывод: Повтор: кот кот

Lookahead и Lookbehind — проверка без захвата

Эти конструкции позволяют проверить, что перед или после совпадения есть определённый текст, но не включать его в результат:

// Положительный lookahead: X(?=Y) — X, за которым следует Y
Pattern pattern = Pattern.compile("\d+(?= рублей)");
Matcher matcher = pattern.matcher("100 рублей, 200 долларов");
// Найдёт "100", потому что после него идёт " рублей"
// "200" не найдёт, так как после него " долларов"

// Отрицательный lookahead: X(?!Y) — X, за которым НЕ следует Y
Pattern pattern2 = Pattern.compile("\d+(?! рублей)");
// Найдёт "200", так как после него не " рублей"

// Lookbehind: (?<=X)Y — Y, перед которым идёт X
Pattern pattern3 = Pattern.compile("(?<=\$)\d+");
// Найдёт цифры, перед которыми стоит знак доллара

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

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

public class Example1 {
    public static void main(String[] args) {
        // Задача: проверить, состоит ли строка только из цифр
        String text1 = "12345";
        String text2 = "12a45";
        String text3 = "  123  "; // с пробелами
        
        // Шаблон: ^ — начало, d+ — одна или более цифр, $ — конец
        String regex = "^\d+$";
        
        System.out.println(text1 + " — число? " + text1.matches(regex)); // true
        System.out.println(text2 + " — число? " + text2.matches(regex)); // false
        System.out.println(text3 + " — число? " + text3.matches(regex)); // false
        
        // Объяснение:
        // ^ — якорь начала строки. Без него строка могла бы начинаться с чего угодно
        // d+ — одна или несколько цифр
        // $ — якорь конца строки. Без него строка могла бы заканчиваться чем угодно
        // Вместе ^ и $ гарантируют, что ВСЯ строка состоит только из цифр
    }
}

Пример 2: Извлечение всех email-адресов из текста

import java.util.regex.*;
import java.util.ArrayList;

public class Example2 {
    public static void main(String[] args) {
        String text = "Контакты: ivan@mail.ru, petr@yandex.ru. " +
                      "Также пишите на support@company.com или admin@site.org";
        
        // Шаблон для email:
        // [w.-]+ — имя пользователя (буквы, цифры, точки, дефисы)
        // @ — собачка
        // [w.-]+ — домен
        // . — точка (экранированная)
        // [a-zA-Z]{2,} — доменная зона (ru, com, org и т.д.)
        String regex = "[\w.-]+@[\w.-]+\.[a-zA-Z]{2,}";
        
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(text);
        
        ArrayList emails = new ArrayList<>();
        
        // find() ищет следующее совпадение, пока они есть
        while (matcher.find()) {
            emails.add(matcher.group()); // group() возвращает найденный текст
        }
        
        System.out.println("Найденные email-адреса:");
        for (String email : emails) {
            System.out.println("  - " + email);
        }
        
        // Вывод:
        // Найденные email-адреса:
        //   - ivan@mail.ru
        //   - petr@yandex.ru
        //   - support@company.com
        //   - admin@site.org
    }
}

Пример 3: Замена всех номеров телефонов на скрытый формат

public class Example3 {
    public static void main(String[] args) {
        String text = "Позвоните по номеру +7 (123) 456-78-90 или 8-800-555-35-35";
        
        // Шаблон для российского номера:
        // +?7|8 — начинается с +7 или 8
        // [s-()]* — возможные разделители (пробелы, дефисы, скобки)
        // d{3} — три цифры (код города или первые три)
        // [s-()]* — снова разделители
        // d{3}[s-]*d{2}[s-]*d{2} — остальные цифры
        String regex = "(\+?7|8)[\s\-\(\)]*(\d{3})[\s\-\(\)]*(\d{3})[\s\-]*(\d{2})[\s\-]*(\d{2})";
        
        // Заменяем на скрытый формат, используя группы
        // $1 — первая группа (+7 или 8)
        // $2 — вторая группа (первые три цифры)
        // Остальные заменяем на ***
        String result = text.replaceAll(regex, "$1 ($2) ***-**-**");
        
        System.out.println("Исходный текст: " + text);
        System.out.println("После замены:   " + result);
        
        // Вывод:
        // Исходный текст: Позвоните по номеру +7 (123) 456-78-90 или 8-800-555-35-35
        // После замены:   Позвоните по номеру +7 (123) ***-**-** или 8 (800) ***-**-**
    }
}

Пример 4: Разбор лога сервера

import java.util.regex.*;

public class Example4 {
    public static void main(String[] args) {
        // Представим, что у нас есть лог веб-сервера
        String logLine = "192.168.1.1 - - [10/Oct/2023:13:55:36 +0300] "GET /index.html HTTP/1.1" 200 2326";
        
        // Разобьём лог на части с помощью групп
        // (d+.d+.d+.d+) — IP-адрес (группа 1)
        // [([^]]+)] — дата в квадратных скобках (группа 2)
        // "([^"]+)" — запрос в кавычках (группа 3)
        // (d+) — статус-код (группа 4)
        // (d+) — размер ответа (группа 5)
        String regex = "(\d+\.\d+\.\d+\.\d+).*\[([^\]]+)\]\s*"([^"]+)"\s*(\d+)\s*(\d+)";
        
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(logLine);
        
        if (matcher.matches()) { // matches() — вся строка должна совпасть
            System.out.println("IP-адрес:      " + matcher.group(1));
            System.out.println("Дата и время:  " + matcher.group(2));
            System.out.println("Запрос:        " + matcher.group(3));
            System.out.println("Статус-код:    " + matcher.group(4));
            System.out.println("Размер ответа: " + matcher.group(5) + " байт");
        }
        
        // Вывод:
        // IP-адрес:      192.168.1.1
        // Дата и время:  10/Oct/2023:13:55:36 +0300
        // Запрос:        GET /index.html HTTP/1.1
        // Статус-код:    200
        // Размер ответа: 2326 байт
    }
}

Пример 5: Валидация пароля

public class Example5 {
    public static void main(String[] args) {
        // Задача: пароль должен содержать:
        // - минимум 8 символов
        // - хотя бы одну заглавную букву
        // - хотя бы одну строчную букву
        // - хотя бы одну цифру
        // - хотя бы один спецсимвол (!@#$%^&*)
        
        String[] passwords = {
            "weak",           // слишком короткий
            "12345678",       // только цифры
            "Password",       // нет цифр и спецсимволов
            "Password1",      // нет спецсимвола
            "Pass1!",         // слишком короткий
            "StrongP@ss1"     // подходит!
        };
        
        // Используем lookahead для проверки каждого условия
        // (?=.*[A-Z]) — где-то есть заглавная буква
        // (?=.*[a-z]) — где-то есть строчная буква
        // (?=.*d) — где-то есть цифра
        // (?=.*[!@#$%^&*]) — где-то есть спецсимвол
        // .{8,} — минимум 8 символов
        String regex = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$";
        
        for (String password : passwords) {
            boolean isValid = password.matches(regex);
            System.out.println(password + " -> " + (isValid ? "✅ подходит" : "❌ не подходит"));
        }
        
        // Вывод:
        // weak -> ❌ не подходит
        // 12345678 -> ❌ не подходит
        // Password -> ❌ не подходит
        // Password1 -> ❌ не подходит
        // Pass1! -> ❌ не подходит
        // StrongP@ss1 -> ✅ подходит
    }
}

Пример 6: Поиск и подсчёт слов в тексте

import java.util.regex.*;
import java.util.HashMap;
import java.util.Map;

public class Example6 {
    public static void main(String[] args) {
        String text = "Кот и собака. Кот любит молоко, а собака любит кости. " +
                      "Кот и собака — друзья?";
        
        // Найдём все слова (только буквы, минимум 2 символа)
        //  — граница слова
        // [а-яА-ЯёЁ]+ — одна или более русских букв
        // {2,} — минимум 2 символа (чтобы исключить союзы "и", "а")
        String regex = "\b[а-яА-ЯёЁ]{2,}\b";
        
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(text.toLowerCase()); // приводим к нижнему регистру
        
        Map wordCount = new HashMap<>();
        
        while (matcher.find()) {
            String word = matcher.group();
            wordCount.put(word, wordCount.getOrDefault(word, 0) + 1);
        }
        
        System.out.println("Статистика слов:");
        for (Map.Entry entry : wordCount.entrySet()) {
            System.out.println("  " + entry.getKey() + ": " + entry.getValue());
        }
        
        // Вывод:
        // Статистика слов:
        //   кот: 3
        //   собака: 2
        //   любит: 2
        //   молоко: 1
        //   кости: 1
        //   друзья: 1
    }
}

Пример 7: Работа с многострочным текстом

import java.util.regex.*;

public class Example7 {
    public static void main(String[] args) {
        String multilineText = "Строка 1: Привет
" +
                               "Строка 2: Мир
" +
                               "Строка 3: Java
" +
                               "Строка 4: Regex
" +
                               "Строка 5: Конец";
        
        // Найдём все строки, которые начинаются с "Строка"
        // Без флага MULTILINE ^ означает начало всей строки
        // С флагом MULTILINE ^ означает начало каждой строки
        
        Pattern patternWithoutFlag = Pattern.compile("^Строка.*");
        Pattern patternWithFlag = Pattern.compile("^Строка.*", Pattern.MULTILINE);
        
        System.out.println("Без MULTILINE:");
        Matcher m1 = patternWithoutFlag.matcher(multilineText);
        while (m1.find()) {
            System.out.println("  " + m1.group());
        }
        // Вывод: только первая строка
        
        System.out.println("
С MULTILINE:");
        Matcher m2 = patternWithFlag.matcher(multilineText);
        while (m2.find()) {
            System.out.println("  " + m2.group());
        }
        // Вывод: все пять строк
        
        // Ещё один полезный флаг — DOTALL
        // Без него точка (.) не совпадает с переводом строки
        // С DOTALL точка совпадает с любым символом, включая 

        
        Pattern patternDotAll = Pattern.compile("Строка.*Конец", Pattern.DOTALL);
        Matcher m3 = patternDotAll.matcher(multilineText);
        if (m3.find()) {
            System.out.println("
С DOTALL найдено: " + m3.group());
        }
        // Найдёт весь текст от "Строка" до "Конец", включая переносы строк
    }
}

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

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

Характеристика Java Python
Импорт import java.util.regex.* import re
Компиляция шаблона Pattern.compile("regex") re.compile(r"regex")
Поиск первого совпадения matcher.find() re.search(pattern, text)
Поиск всех совпадений Цикл while(matcher.find()) re.findall(pattern, text)
Проверка полного совпадения str.matches(regex) или matcher.matches() re.fullmatch(pattern, text)
Замена str.replaceAll(regex, replacement) re.sub(pattern, replacement, text)
Сырые строки (raw strings) Нет. Нужно экранировать обратные слеши: "\d" Есть. r"d" — обратные слеши не экранируются
Флаги Константы класса Pattern: Pattern.CASE_INSENSITIVE Флаги в виде букв: re.IGNORECASE или re.I
Lookbehind Поддерживается, но длина должна быть фиксированной Поддерживается, длина может быть переменной (в новых версиях)
Производительность Высокая, но требуется явная компиляция Pattern Хорошая, но при многократном использовании лучше компилировать
💡 Главное отличие для новичка: В Java нужно экранировать обратные слеши. Если в Python ты пишешь r"d", то в Java — "\d". Это связано с тем, что в Java строки тоже используют обратный слеш для escape-последовательностей ( , ). Поэтому для передачи одного обратного слеша в regex нужно написать два.

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

Ошибка 1: Забыли экранировать обратный слеш

// ❌ Неправильно
Pattern pattern = Pattern.compile("d+"); // Java думает, что d — это неизвестная escape-последовательность

// ✅ Правильно
Pattern pattern = Pattern.compile("\d+"); // Два слеша — один для Java, один для regex

Ошибка 2: Путаница между matches() и find()

String text = "Привет, мир!";
String regex = "мир";

// matches() проверяет, совпадает ли ВСЯ строка
System.out.println(text.matches(regex)); // false — строка не равна "мир"

// find() ищет подстроку