$ sudo teach IT
МОДУЛЬ 11 · УРОК 6

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

Научимся работать с шаблонами поиска текста: от простых совпадений до сложных валидаций email и телефонов. Разберём классы Pattern и Matcher, все основные конструкции regex и множество практических примеров.

~40 минут Для новичков Java

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

Представьте, что вы ищете特定ное слово в огромной книге. Без регулярных выражений вы читали бы страницу за страницей, вручную сравнивая каждое слово. А теперь представьте, что у вас есть волшебная лупа, которая сама находит все слова по вашему описанию — например: «найди все слова, которые начинаются на букву «А», содержат ровно 5 букв и заканчиваются на «ов». Это и есть регулярные выражения!

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

  • Проверка email-адреса: ввели ли пользователь адрес в правильном формате?
  • Поиск телефонных номеров: найти все номера телефона в документе, даже если они записаны по-разному: +7(999)123-45-67, 8 999 123 45 67, +79991234567
  • Валидация пароля: содержит ли пароль минимум 8 символов, хотя бы одну заглавную букву и одну цифру?
  • Замена текста: заменить все повторяющиеся пробелы на один
  • Извлечение данных: извлечь все даты из текста в формате ДД.ММ.ГГГГ

Давайте начнём с самого простого примера. Допустим, у нас есть строка и мы хотим проверить, содержит ли она слово «привет»:

public class RegexBasic {
    public static void main(String[] args) {
        String text = "Привет, мир!";
        
        // Самый простой способ — использовать matches()
        boolean contains = text.matches(".*привет.*");
        System.out.println(contains);
    }
}

Здесь .*привет.* — это регулярное выражение. Оно означает: «любые символы (.*), затем слово «привет», затем снова любые символы (.*).» Метод matches() проверяет, соответствует ли вся строка этому шаблону.

Важно понимать: Регулярные выражения — это не просто «поиск текста». Это язык описания шаблонов. Вы описываете, КАКИМ должен быть текст, а компьютер сам ищет все совпадения. Это гораздо мощнее простого поиска по строке!

Ещё один простой пример — проверка, является ли строка числом:

public class RegexIsDigit {
    public static void main(String[] args) {
        String number1 = "12345";
        String number2 = "12abc";
        String number3 = "12.5";
        
        // \d+ означает: одна или более цифр
        System.out.println(number1.matches("\\d+"));  // true
        System.out.println(number2.matches("\\d+"));  // false
        System.out.println(number3.matches("\\d+"));  // false
        
        // А это — любое количество цифр (включая 0)
        System.out.println(number1.matches("\\d*"));  // true
        System.out.println(number2.matches("\\d*"));  // false
    }
}

Здесь \\d+ означает «одна или более цифр». Обратите внимание на двойной обратный слэш — в Java строках символ \ является экранирующим, поэтому чтобы получить один обратный слэш в regex, нужно написать \\ в строке Java.

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

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

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

Создадим наш первый Pattern:

import java.util.regex.Pattern;

public class PatternExample {
    public static void main(String[] args) {
        // Компилируем регулярное выражение в объект Pattern
        Pattern pattern = Pattern.compile("привет");
        
        // Проверяем, что строка соответствует шаблону
        boolean matches = pattern.matcher("привет, мир!").matches();
        System.out.println(matches);  // false — entire string must match
    }
}

Здесь метод matches() проверяет, соответствует ли ВСЯ строка шаблону. Строка «привет, мир!» не соответствует шаблону «привет» целиком — там ещё есть «, мир!». Поэтому результат false. Чтобы найти подстроку, нужен метод find() класса Matcher, который мы разберём позже.

Флаги Pattern: настройка поведения

При компиляции можно указать «флаги» — параметры, которые меняют поведение regex. Это как настройки в приложении: вы можете включить/выключить определённые опции.

CASE_INSENSITIVE — игнорировать регистр букв. Это очень полезно, когда вы ищете слово, но не знаете, в каком регистре оно написано:

import java.util.regex.Pattern;

public class CaseInsensitiveExample {
    public static void main(String[] args) {
        // Без флага — регистр важен
        Pattern caseSensitive = Pattern.compile("привет");
        System.out.println(caseSensitive.matcher("Привет").matches());  // false
        
        // С флагом — регистр не важен
        Pattern caseInsensitive = Pattern.compile("привет", Pattern.CASE_INSENSITIVE);
        System.out.println(caseInsensitive.matcher("Привет").matches());  // true
        System.out.println(caseInsensitive.matcher("ПРИВЕТ").matches());  // true
        System.out.println(caseInsensitive.matcher("привет").matches());  // true
    }
}

MULTILINE — позволяет символам ^ и $ работать для каждой строки, а не только для всей строки:

import java.util.regex.Pattern;

public class MultilineExample {
    public static void main(String[] args) {
        String text = "Первая строка\nВторая строка\nТретья строка";
        
        // Без MULTILINE — ^ означает начало всей строки
        Pattern withoutMultiline = Pattern.compile("^Вторая");
        System.out.println(withoutMultiline.matcher(text).find());  // false
        
        // С MULTILINE — ^ означает начало каждой строки
        Pattern withMultiline = Pattern.compile("^Вторая", Pattern.MULTILINE);
        System.out.println(withMultiline.matcher(text).find());  // true
    }
}

DOTALL — позволяет символу «точка» (.) совпадать с символом новой строки (\n):

import java.util.regex.Pattern;

public class DotAllExample {
    public static void main(String[] args) {
        String text = "Привет\nмир";
        
        // Без DOTALL — точка не совпадает с \n
        Pattern withoutDotAll = Pattern.compile("Привет.*мир");
        System.out.println(withoutDotAll.matcher(text).matches());  // false
        
        // С DOTALL — точка совпадает с любым символом, включая \n
        Pattern withDotAll = Pattern.compile("Привет.*мир", Pattern.DOTALL);
        System.out.println(withDotAll.matcher(text).matches());  // true
    }
}

Можно комбинировать несколько флагов, используя побитовое ИЛИ (|):

import java.util.regex.Pattern;

public class MultipleFlagsExample {
    public static void main(String[] args) {
        // CASE_INSENSITIVE | MULTILINE — оба флага одновременно
        Pattern pattern = Pattern.compile(
            "^привет$", 
            Pattern.CASE_INSENSITIVE | Pattern.MULTILINE
        );
        
        String text = "Привет\nПРИВЕТ\nпривет\nДругое";
        System.out.println(pattern.matcher(text).find());  // true
    }
}

Аналогия: Подумайте о Pattern как о «штампе». Вы создаёте штамп (компилируете regex), а потом прикладываете его к разным документам (строкам), чтобы проверить, подходят ли они. Штамп можно настроить (флаги): например, сделать его нечувствительным к регистру.

Класс Matcher: поиск и извлечение совпадений

Если Pattern — это «рецепт», то Matcher — это «повар», который этот рецепт выполняет. Matcher применяет шаблон к конкретной строке и находит все совпадения.

Создадим Matcher и разберём его основные методы:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class MatcherBasic {
    public static void main(String[] args) {
        Pattern pattern = Pattern.compile("\\d+");
        Matcher matcher = pattern.matcher("Абырвалг 123 строка 456");
        
        // find() — ищет следующее совпадение
        while (matcher.find()) {
            System.out.println("Найдено: " + matcher.group());
            System.out.println("Начало: " + matcher.start());
            System.out.println("Конец: " + matcher.end());
            System.out.println("---");
        }
    }
}

Вывод программы:

Найдено: 123
Начало: 8
Конец: 11
---
Найдено: 456
Начало: 20
Конец: 23
---

Метод matches() — проверка всей строки

Метод matches() проверяет, соответствует ли ВСЯ строка шаблону. Это строгая проверка — от первого до последнего символа:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class MatchesExample {
    public static void main(String[] args) {
        Pattern pattern = Pattern.compile("\\d{3}-\\d{2}-\\d{2}");
        
        // matches() требует, чтобы ВСЯ строка соответствовала шаблону
        Matcher m1 = pattern.matcher("123-45-67");
        System.out.println(m1.matches());  // true — вся строка совпадает
        
        Matcher m2 = pattern.matcher("Номер: 123-45-67");
        System.out.println(m2.matches());  // false — есть лишний текст
        
        Matcher m3 = pattern.matcher("123-45-67 лишний текст");
        System.out.println(m3.matches());  // false — есть лишний текст
    }
}

Метод find() — поиск подстрок

Метод find() ищет следующее вхождение шаблона в строке. Он возвращает true, если совпадение найдено, и false, если больше совпадений нет. Это как листать книгу страница за страницей и отмечать нужные места:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class FindExample {
    public static void main(String[] args) {
        String text = "В слове «привет» 6 букв, а в слове «мир» — 3";
        Pattern pattern = Pattern.compile("\\b\\w+\\b");
        
        Matcher matcher = pattern.matcher(text);
        while (matcher.find()) {
            System.out.println("Слово: «" + matcher.group() + "»");
        }
    }
}

Здесь \b обозначает границу слова, а \w+ — одно или более буквенно-цифровых символов. Программа выведет все слова из текста.

Метод group() — извлечение совпадения

После каждого успешного вызова find() вы можете получить найденную подстроку методом group():

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class GroupExample {
    public static void main(String[] args) {
        String text = "Телефоны: +7(999)123-45-67 и +7(911)987-65-43";
        Pattern pattern = Pattern.compile("\\+7\\(\\d{3}\\)\\d{3}-\\d{2}-\\d{2}");
        
        Matcher matcher = pattern.matcher(text);
        while (matcher.find()) {
            System.out.println("Найден телефон: " + matcher.group());
        }
    }
}

Программа выведет оба найденных телефона. Метод group() без аргумента возвращает всю строку, соответствующую шаблону (эквивалент group(0)).

Методы start() и end() — позиция совпадения

Методы start() и end() возвращают позиции начала и конца совпадения в строке. Это полезно, когда нужно знать, ГДЕ именно в тексте находится найденный фрагмент:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class PositionExample {
    public static void main(String[] args) {
        String text = "Я люблю Java и Python";
        Pattern pattern = Pattern.compile("\\b(Java|Python)\\b");
        
        Matcher matcher = pattern.matcher(text);
        while (matcher.find()) {
            System.out.println("Язык: " + matcher.group());
            System.out.println("Позиция: " + matcher.start() + " - " + matcher.end());
            System.out.println("Контекст: ..." + text.substring(
                Math.max(0, matcher.start() - 5), 
                Math.min(text.length(), matcher.end() + 5)
            ) + "...");
            System.out.println("---");
        }
    }
}

Этот пример найдёт оба языка программирования и покажет их позицию в строке, а также небольшой контекст вокруг каждого совпадения.

Важно: Метод find() можно вызывать многократно — каждый раз он ищет следующее совпадение после предыдущего. Когда совпадений больше нет, он возвращает false. Обычно его используют в цикле while(matcher.find()).

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

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

Точка (.) — любой символ

Точка совпадает с ЛЮБЫМ одиночным символом (кроме символа новой строки, если не установлен флаг DOTALL). Это как « wild карта » в карточных играх:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class DotExample {
    public static void main(String[] args) {
        Pattern pattern = Pattern.compile("пр.вет");
        
        System.out.println(pattern.matcher("привет").matches());   // true
        System.out.println(pattern.matcher("прпвет").matches());   // true
        System.out.println(pattern.matcher("пр-вет").matches());   // true
        System.out.println(pattern.matcher("прывет").matches());   // true
        System.out.println(pattern.matcher("првет").matches());    // false — нет символа
        System.out.println(pattern.matcher("привети").matches());  // false — лишний символ
    }
}

Квадратные скобки [] — набор символов

Квадратные скобки определяют «набор» символов. Регулярное выражение совпадёт с любым ОДНИМ символом из этого набора:

import java.util.regex.Pattern;

public class CharacterSetExample {
    public static void main(String[] args) {
        // [abc] — совпадает с a, b или c
        Pattern vowels = Pattern.compile("[aeiouy]");
        System.out.println(vowels.matcher("a").matches());   // true
        System.out.println(vowels.matcher("e").matches());   // true
        System.out.println(vowels.matcher("x").matches());   // false
        
        // [a-z] — диапазон от a до z
        Pattern lowerLetter = Pattern.compile("[a-z]");
        System.out.println(lowerLetter.matcher("m").matches());   // true
        System.out.println(lowerLetter.matcher("M").matches());   // false
        
        // [a-zA-Z] — любая латинская буква
        Pattern anyLetter = Pattern.compile("[a-zA-Z]");
        System.out.println(anyLetter.matcher("A").matches());   // true
        System.out.println(anyLetter.matcher("z").matches());   // true
        
        // [0-9] — любая цифра
        Pattern digit = Pattern.compile("[0-9]");
        System.out.println(digit.matcher("5").matches());   // true
        System.out.println(digit.matcher("a").matches());   // false
    }
}

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

Символ ^ обозначает начало строки, а $ — конец строки. Это как проверка «от края до края»:

import java.util.regex.Pattern;

public class AnchorsExample {
    public static void main(String[] args) {
        // ^Hello — строка должна НАЧИНАТЬСЯ с Hello
        Pattern startsWith = Pattern.compile("^Hello");
        System.out.println(startsWith.matcher("Hello World").matches());  // true
        System.out.println(startsWith.matcher("Say Hello").matches());   // false
        
        // World$ — строка должна ЗАКАНЧИВАТЬСЯ на World
        Pattern endsWith = Pattern.compile("World$");
        System.out.println(endsWith.matcher("Hello World").matches());  // true
        System.out.println(endsWith.matcher("World Hello").matches());  // false
        
        // ^Hello$ — строка должна быть ТОЧНО "Hello"
        Pattern exact = Pattern.compile("^Hello$");
        System.out.println(exact.matcher("Hello").matches());      // true
        System.out.println(exact.matcher("Hello World").matches()); // false
    }
}

Аналогия: Подумайте о ^ и $ как о «стенам» по краям строки. ^Hello — значит «слово должно упираться в левую стену», а World$ — «упираться в правую».

Скобки () — группы

Скобки создают «группы захвата» — они позволяют выделить часть совпадения и работать с ней отдельно. Это как выделить фрагмент текста маркером:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class GroupsExample {
    public static void main(String[] args) {
        // Захватываем день, месяц и год из даты
        Pattern pattern = Pattern.compile("(\\d{2})\\.(\\d{2})\\.(\\d{4})");
        Matcher matcher = pattern.matcher("Дата: 25.12.2023");
        
        if (matcher.find()) {
            System.out.println("Полное совпадение: " + matcher.group(0));  // 25.12.2023
            System.out.println("День: " + matcher.group(1));              // 25
            System.out.println("Месяц: " + matcher.group(2));             // 12
            System.out.println("Год: " + matcher.group(3));               // 2023
        }
    }
}

Каждая пара скобок — это отдельная группа. Группа нумеруется с 1. Группа 0 — это всегда всё совпадение целиком.

Фигурные скобки {} — количество повторений

Фигурные скобки позволяют указать точное количество повторений или диапазон:

import java.util.regex.Pattern;

public class QuantifierBracesExample {
    public static void main(String[] args) {
        // {3} — ровно 3 раза
        Pattern exactly3 = Pattern.compile("а{3}");
        System.out.println(exactly3.matcher("ааа").matches());    // true
        System.out.println(exactly3.matcher("аааа").matches());   // false
        
        // {2,4} — от 2 до 4 раз
        Pattern between2and4 = Pattern.compile("а{2,4}");
        System.out.println(between2and4.matcher("аа").matches());     // true
        System.out.println(between2and4.matcher("аааааа").matches()); // false — 6 раз
        
        // {3,} — 3 и более раз
        Pattern atLeast3 = Pattern.compile("а{3,}");
        System.out.println(atLeast3.matcher("ааа").matches());     // true
        System.out.println(atLeast3.matcher("аааааааа").matches()); // true
    }
}

Экранирование спецсимволов

Некоторые символы имеют специальное значение в regex: . * + ? ^ $ [] () {} \ |. Если вы хотите найти ЛИТЕРАЛЬНЫЙ символ (а не использовать его как спецсимвол), нужно «экранировать» его обратным слэшем:

import java.util.regex.Pattern;

public class EscapeExample {
    public static void main(String[] args) {
        // Ищемliteralную точку (не "любой символ")
        Pattern dot = Pattern.compile("\\.");
        System.out.println(dot.matcher(".").matches());     // true
        System.out.println(dot.matcher("a").matches());     // false
        
        // Ищемliteralную звёздочку
        Pattern star = Pattern.compile("\\*");
        System.out.println(star.matcher("*").matches());    // true
        System.out.println(star.matcher("a").matches());    // false
        
        // Ищемliteralную скобку
        Pattern parenthesis = Pattern.compile("\\(");
        System.out.println(parenthesis.matcher("(").matches()); // true
        System.out.println(parenthesis.matcher(")").matches()); // false
    }
}

В Java строках обратный слэш сам по себе является спецсимволом, поэтому нужно удваивать: \\. для точки, \\* для звёздочки и т.д.

Классы символов: \\d, \\w, \\s и другие

Вместо того чтобы перечислять все возможные символы в квадратных скобках, можно использовать «классы символов» — готовые наборы, обозначённые специальными последовательностями. Это как сокращения: вместо того чтобы писать «все цифры», вы просто пишете \d.

\d — любая цифра (digit)

Эквивалент [0-9]. Совпадает с любой цифрой от 0 до 9:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class DigitExample {
    public static void main(String[] args) {
        String text = "В моём номере 123456, а в твоём 789012";
        Pattern pattern = Pattern.compile("\\d");
        
        Matcher matcher = pattern.matcher(text);
        int count = 0;
        while (matcher.find()) {
            count++;
            System.out.println("Цифра " + count + ": " + matcher.group() + " на позиции " + matcher.start());
        }
        System.out.println("Всего цифр: " + count);
    }
}

\D — любая не-цифра

Это «инверсия» \d. Совпадает с любым символом, КРОМЕ цифры:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class NotDigitExample {
    public static void main(String[] args) {
        String text = "АБ123ВГ";
        Pattern pattern = Pattern.compile("\\D");
        
        Matcher matcher = pattern.matcher(text);
        while (matcher.find()) {
            System.out.println("Не-цифра: «" + matcher.group() + "» на позиции " + matcher.start());
        }
    }
}

\w — буква, цифра или подчёркивание (word)

Эквивалент [a-zA-Z0-9_]. Совпадает с любой «словесной» буквой (латиницей), цифрой или подчёркиванием:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class WordCharExample {
    public static void main(String[] args) {
        String text = "variable_name = 123; // comment";
        Pattern pattern = Pattern.compile("\\w+");
        
        Matcher matcher = pattern.matcher(text);
        while (matcher.find()) {
            System.out.println("Слово: «" + matcher.group() + "»");
        }
    }
}

Этот пример найдёт все «слова» (буквенно-цифровые последовательности) в строке кода.

\W — не «словесный» символ

Инверсия \w. Совпадает с любым символом, КРОМЕ букв, цифр и подчёркивания — то есть пробелы, знаки препинания, специальные символы:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class NotWordCharExample {
    public static void main(String[] args) {
        String text = "Привет, мир!";
        Pattern pattern = Pattern.compile("\\W");
        
        Matcher matcher = pattern.matcher(text);
        while (matcher.find()) {
            System.out.println("Не-словесный символ: «" + matcher.group() + "» на позиции " + matcher.start());
        }
    }
}

\s — пробел или другой whitespace

Совпадает с любым пробельным символом: обычный пробел, табуляция (\t), перенос строки (\n), возврат каретки (\r) и другие:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class WhitespaceExample {
    public static void main(String[] args) {
        String text = "Привет\tмир\n新的一行";
        Pattern pattern = Pattern.compile("\\s");
        
        Matcher matcher = pattern.matcher(text);
        while (matcher.find()) {
            System.out.println("Пробел на позиции " + matcher.start() + 
                " (тип: " + (int) matcher.group().charAt(0) + ")");
        }
    }
}

\S — непробельный символ

Инверсия \s. Совпадает с любым символом, КРОМЕ пробельных:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class NotWhitespaceExample {
    public static void main(String[] args) {
        String text = "А Б В";
        Pattern pattern = Pattern.compile("\\S+");
        
        Matcher matcher = pattern.matcher(text);
        while (matcher.find()) {
            System.out.println("Непробельная часть: «" + matcher.group() + "»");
        }
    }
}

\b — граница слова

Совпадает с позицией «на границе слова» — между «словесным» и «не-словесным» символом. Это не символ, а «невидимая позиция». Очень полезно для поиска точных слов:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class WordBoundaryExample {
    public static void main(String[] args) {
        String text = "кот каталог катушка";
        
        // Ищем точное слово "кот" (без "каталог" и "катушка")
        Pattern pattern = Pattern.compile("\\bкот\\b");
        Matcher matcher = pattern.matcher(text);
        
        while (matcher.find()) {
            System.out.println("Точное совпадение: «" + matcher.group() + "»");
        }
        
        System.out.println();
        
        // Без \b — найдёт "кот" и в "каталог", и в "катушка"
        Pattern pattern2 = Pattern.compile("кот");
        Matcher matcher2 = pattern2.matcher(text);
        while (matcher2.find()) {
            System.out.println("Простой поиск: «" + matcher2.group() + "» на позиции " + matcher2.start());
        }
    }
}

Аналогия: Подумайте о классах символов как о «коробках с инструментами». Вместо того чтобы перечислять каждый гаечный ключ отдельно, вы берёте коробку \d (все цифры) или \w (все «словесные» символы). Буква в верхнем регистре (\D, \W, \S) — это «инвертированная» коробка, содержащая ВСЁ, КРОМЕ содержимого нижнерегистровой версии.

Сводная таблица классов символов

Символ Описание Эквивалент
\dЦифра[0-9]
\DНе-цифра[^0-9]
\wБуква, цифра или _[a-zA-Z0-9_]
\WНе «словесный» символ[^a-zA-Z0-9_]
\sПробел или whitespace[ \t\n\r\f]
\SНепробельный символ[^ \t\n\r\f]
\bГраница слова—

Квантификаторы: сколько раз повторять

Квантификаторы определяют, СКОЛЬКО РАЗ предыдущий элемент должен повторяться. Это как «множитель»: вы говорите «букву «а» нужно встретить минимум 2 раза, максимум 5». Давайте разберём все квантификаторы:

* — ноль или более повторений

Звёздочка означает «ноль или более». Элемент может отсутствовать совсем, а может повторяться бесконечно:

import java.util.regex.Pattern;

public class StarExample {
    public static void main(String[] args) {
        // "а*" — ноль или более "а"
        Pattern pattern = Pattern.compile("^а*$");
        
        System.out.println(pattern.matcher("").matches());      // true — ноль "а"
        System.out.println(pattern.matcher("а").matches());     // true — одно "а"
        System.out.println(pattern.matcher("ааааа").matches()); // true — пять "а"
        System.out.println(pattern.matcher("аб").matches());    // false — есть "б"
    }
}

+ — одно или более повторений

Плюс означает «одно или более». Элемент ДОЛЖЕН встретиться хотя бы один раз:

import java.util.regex.Pattern;

public class PlusExample {
    public static void main(String[] args) {
        // "а+" — одно или более "а"
        Pattern pattern = Pattern.compile("^а+$");
        
        System.out.println(pattern.matcher("").matches());      // false — ни одного "а"
        System.out.println(pattern.matcher("а").matches());     // true — одно "а"
        System.out.println(pattern.matcher("ааааа").matches()); // true — пять "а"
        System.out.println(pattern.matcher("аб").matches());    // false — есть "б"
    }
}

? — ноль или одно повторение

Вопросительный знак означает «ноль или одно». Элемент необязателен — он может быть, а может и не быть:

import java.util.regex.Pattern;

public class QuestionExample {
    public static void main(String[] args) {
        // "кол-?ор" — "colo" или "color"
        Pattern pattern = Pattern.compile("кол-?ор");
        
        System.out.println(pattern.matcher("колор").matches());    // true
        System.out.println(pattern.matcher("кол-ор").matches());   // true
        System.out.println(pattern.matcher("кол--ор").matches());  // false — два минуса
    }
}

{n} — ровно n повторений

import java.util.regex.Pattern;

public class ExactlyExample {
    public static void main(String[] args) {
        // \d{4} — ровно 4 цифры (год)
        Pattern year = Pattern.compile("\\d{4}");
        
        System.out.println(year.matcher("2023").matches());  // true
        System.out.println(year.matcher("23").matches());    // false
        System.out.println(year.matcher("20235").matches()); // false
    }
}

{n,m} — от n до m повторений

import java.util.regex.Pattern;

public class RangeExample {
    public static void main(String[] args) {
        // \d{2,4} — от 2 до 4 цифр
        Pattern pattern = Pattern.compile("\\d{2,4}$");
        
        System.out.println(pattern.matcher("12").matches());      // true
        System.out.println(pattern.matcher("123").matches());     // true
        System.out.println(pattern.matcher("1234").matches());    // true
        System.out.println(pattern.matcher("1").matches());       // false
        System.out.println(pattern.matcher("12345").matches());   // false
    }
}

{n,} — n и более повторений

import java.util.regex.Pattern;

public class AtLeastExample {
    public static void main(String[] args) {
        // \d{3,} — 3 и более цифр (минимум 3)
        Pattern pattern = Pattern.compile("\\d{3,}$");
        
        System.out.println(pattern.matcher("123").matches());     // true
        System.out.println(pattern.matcher("1234567").matches()); // true
        System.out.println(pattern.matcher("12").matches());      // false
    }
}

Жадный vs ленивый (greedy vs reluctant)

Это один из самых важных и самых непонятных для новичков моментов. По умолчанию все квантификаторы являются жадными (greedy) — они стараются захватить как МОЖНО БОЛЬШЕ символов. Но иногда нужно обратное — захватить как МОЖНО МЕНЬШЕ. Для этого после квантификатора ставят вопросительный знак:

  • * — жадный (максимум)
  • *? — ленивый (минимум)
  • + — жадный
  • +? — ленивый
  • {n,m} — жадный
  • {n,m}? — ленивый
import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class GreedyVsReluctant {
    public static void main(String[] args) {
        String text = "<p>Привет</p><p>Мир</p>";
        
        // Жадный: .* захватит всё до последнего </p>
        Pattern greedy = Pattern.compile("<p>(.*)</p>");
        Matcher m1 = greedy.matcher(text);
        if (m1.find()) {
            System.out.println("Жадный: «" + m1.group(1) + "»");
        }
        
        // Ленивый: .*? захватит до первого </p>
        Pattern reluctant = Pattern.compile("<p>(.*?)</p>");
        Matcher m2 = reluctant.matcher(text);
        while (m2.find()) {
            System.out.println("Ленивый: «" + m2.group(1) + "»");
        }
    }
}

Жадный квантификатор захватит «Привет</p><p>Мир» целиком, потому что он «жадный» и хочет взять как можно больше. Ленивый захватит только «Привет», а потом на втором проходе — «Мир». Это принципиальная разница!

Аналогия: Жадный квантификатор — как голодный человек за столом: он берёт ВСЁ, что может поместиться в тарелку. Ленивый — как диетолог: он берёт ровно столько, сколько нужно, не больше. Если вам нужно найти содержимое между тегами HTML, ленивый квантификатор часто работает правильнее, потому что он останавливается на ПЕРВОМ закрывающем теге.

Группы и захват подвыражений

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

Простые группы (нумерованные)

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

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class NumberedGroups {
    public static void main(String[] args) {
        // Разбираем email на имя пользователя и домен
        String email = "user@example.com";
        Pattern pattern = Pattern.compile("(.+)@(.+\\.+)");
        Matcher matcher = pattern.matcher(email);
        
        if (matcher.matches()) {
            System.out.println("Полное совпадение (group 0): " + matcher.group(0));
            System.out.println("Имя пользователя (group 1): " + matcher.group(1));
            System.out.println("Домен (group 2): " + matcher.group(2));
            System.out.println("Всего групп: " + matcher.groupCount());
        }
    }
}

Результат:

Полное совпадение (group 0): user@example.com
Имя пользователя (group 1): user
Домен (group 2): example.com
Всего групп: 2

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

Для больших и сложных шаблонов нумерованные группы становятся неудобными — легко запутаться, какая группа что содержит. Именованные группы решают эту проблему:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class NamedGroups {
    public static void main(String[] args) {
        // Извлекаем компоненты URL
        String url = "https://www.example.com:8080/path/to/page?key=value";
        Pattern pattern = Pattern.compile(
            "(?<scheme>https?)://(?<host>[^:]+)(?::(?<port>\\d+))?(?<path>[^?]*)"
        );
        Matcher matcher = pattern.matcher(url);
        
        if (matcher.matches()) {
            System.out.println("Протокол: " + matcher.group("scheme"));
            System.out.println("Хост: " + matcher.group("host"));
            System.out.println("Порт: " + matcher.group("port"));
            System.out.println("Путь: " + matcher.group("path"));
        }
    }
}

Вот как это работает пошагово:

  • (?<scheme>https?) — группа с именем «scheme» захватывает «http» или «https»
  • :// — литеральная строка «://»
  • (?<host>[^:]+) — группа «host» захватывает всё до двоеточия или конца
  • (?::(?<port>\\d+))? — опциональная группа «port» захватывает число после двоеточия
  • (?<path>[^?]*) — группа «path» захватывает путь до вопросительного знака

Незахватывающие группы (?:...)

Иногда вам нужны скобки для группировки (чтобы применить квантификатор к группе символов), но вы НЕ хотите захватывать эту группу. Для этого используют (?:...):

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class NonCapturingGroups {
    public static void main(String[] args) {
        String text = "cat cats dog dogs";
        
        // Захватывающая группа — создаёт group(1)
        Pattern capturing = Pattern.compile("(cat|dog)s?");
        Matcher m1 = capturing.matcher(text);
        while (m1.find()) {
            System.out.println("Захватывающая: совпадение=" + m1.group(0) + ", group(1)=" + m1.group(1));
        }
        
        System.out.println("---");
        
        // Незахватывающая группа — group(1) не создаётся
        Pattern nonCapturing = Pattern.compile("(?:cat|dog)s?");
        Matcher m2 = nonCapturing.matcher(text);
        while (m2.find()) {
            System.out.println("Незахватывающая: совпадение=" + m2.group(0) + ", groupCount=" + m2.groupCount());
        }
    }
}

Вложенные группы

Группы могут быть вложенными друг в друга. Нумерация идёт по порядку открывающих скобок:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class NestedGroups {
    public static void main(String[] args) {
        String text = "2023-12-25";
        Pattern pattern = Pattern.compile("((\\d{4})-(\\d{2})-(\\d{2}))");
        Matcher matcher = pattern.matcher(text);
        
        if (matcher.find()) {
            System.out.println("group(0) - всё: " + matcher.group(0));     // 2023-12-25
            System.out.println("group(1) - вся дата: " + matcher.group(1)); // 2023-12-25
            System.out.println("group(2) - год: " + matcher.group(2));       // 2023
            System.out.println("group(3) - месяц: " + matcher.group(3));     // 12
            System.out.println("group(4) - день: " + matcher.group(4));       // 25
        }
    }
}

Важно: Если совпадение не найдено (например, опциональная группа не сработала), то matcher.group(n) вернёт null, а не пустую строку. Всегда проверяйте на null, если группа не обязательная!

Методы String, работающие с regex

Помимо классов Pattern и Matcher, в Java есть методы класса String, которые принимают регулярные выражения. Они удобны для простых операций, когда вам не нужна полная мощь Pattern/Matcher:

String.matches() — полное соответствие

Этот метод проверяет, соответствует ли ВСЯ строка шаблону. Он создаёт временный Pattern и Matcher под капотом:

public class StringMatches {
    public static void main(String[] args) {
        String phone = "+7(999)123-45-67";
        
        // Проверяем формат телефона
        boolean valid = phone.matches("\\+7\\(\\d{3}\\)\\d{3}-\\d{2}-\\d{2}");
        System.out.println("Номер валиден: " + valid);
        
        // Простые проверки
        String number = "12345";
        System.out.println(number.matches("\\d+"));          // true
        System.out.println(number.matches("[a-z]+"));        // false
        System.out.println(number.matches("[a-zA-Z0-9]+"));  // true
    }
}

String.split() — разбиение строки

Метод split() разбивает строку по шаблону, как нож разрезает батон на кусочки:

public class StringSplit {
    public static void main(String[] args) {
        String csv = "яблоко,банан,  вишня,   дыня";
        
        // Разбиваем по запятой с возможными пробелами
        String[] fruits = csv.split(",\\s*");
        for (String fruit : fruits) {
            System.out.println("Фрукт: «" + fruit + "»");
        }
        
        System.out.println("---");
        
        // Разбиваем строку с несколькими разделителями
        String mixed = "один:два;три|четыре";
        String[] parts = mixed.split("[:;|]");
        for (String part : parts) {
            System.out.println("Часть: «" + part + "»");
        }
        
        System.out.println("---");
        
        // Разбиваем по цифрам
        String withNumbers = "abc123def456ghi";
        String[] letters = withNumbers.split("\\d+");
        for (String letter : letters) {
            System.out.println("Буквы: «" + letter + "»");
        }
    }
}

String.replaceAll() — замена по шаблону

Этот метод заменяет все вхождения шаблона на указанную строку:

public class StringReplaceAll {
    public static void main(String[] args) {
        String text = "Привет   мир!   Как   дела?";
        
        // Заменяем несколько пробелов на один
        String cleaned = text.replaceAll("\\s+", " ");
        System.out.println("До: «" + text + "»");
        System.out.println("После: «" + cleaned + "»");
        
        System.out.println("---");
        
        // Заменяем все буквы на звёздочки
        String secret = "Hello World".replaceAll("[a-zA-Z]", "*");
        System.out.println("Секрет: " + secret);
        
        System.out.println("---");
        
        // Используем группы в замене ($1, $2 и т.д.)
        String name = "Иванов Иван Иванович";
        String formatted = name.replaceAll("(\\w+)\\s(\\w)\\w+\\s(\\w)\\w+", "$1 $2. $3.");
        System.out.println("Форматированное: " + formatted);
    }
}

Обратите внимание на последний пример: $1, $2, $3 ссылаются на группы захвата. Это позволяет «перекомбинировать» текст по-новому!

Совет: Если вам нужно использовать regex многократно с одной и той же строкой, лучше скомпилировать Pattern заранее, а не использовать методы String. Методы String каждый раз создают новый Pattern, что менее эффективно. Но для одноразовых операций методы String удобнее.

Практический пример: валидация email

Давайте создадим полноценную проверку email-адреса. Email имеет формат: local@domain.extension. Разберём каждый компонент:

Анализ структуры email

  • Локальная часть (до @): может содержать буквы, цифры, точки, подчёркивания, дефисы, плюсы. Длина от 1 до 64 символов.
  • Символ @: ровно один.
  • Домен (после @): буквы, цифры, дефисы. Длина от 1 до 253 символов.
  • Точка: ровно одна перед расширением.
  • Расширение: буквы, длина от 2 до 63 символов.

Создаём regex для email

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class EmailValidator {
    
    // Паттерн для проверки email
    private static final Pattern EMAIL_PATTERN = Pattern.compile(
        "^[a-zA-Z0-9]" +                    // Первая буква/цифра
        "[a-zA-Z0-9._%+-]*" +                // Остальные символы локальной части
        "@" +                                 // Символ @
        "[a-zA-Z0-9]" +                      // Первая буква/цифра домена
        "[a-zA-Z0-9.-]*" +                   // Остальные символы домена
        "\\." +                               // Точка перед расширением
        "[a-zA-Z]{2,63}$"                    // Расширение (2-63 буквы)
    );
    
    public static boolean isValidEmail(String email) {
        if (email == null || email.isEmpty()) {
            return false;
        }
        return EMAIL_PATTERN.matcher(email).matches();
    }
    
    public static void main(String[] args) {
        // Тестовые email-адреса
        String[] emails = {
            "user@example.com",           // валидный
            "user.name@example.com",      // валидный (точка в локальной части)
            "user+tag@example.com",       // валидный (плюс в локальной части)
            "user@sub.example.com",       // валидный (поддомен)
            "@example.com",               // невалидный (нет локальной части)
            "user@",                      // невалидный (нет домена)
            "user@example",               // невалидный (нет расширения)
            "user@example.c",             // невалидный (расширение 1 буква)
            "user@@example.com",          // невалидный (двойной @)
            "user name@example.com",      // невалидный (пробел)
            "user@exam ple.com",          // невалидный (пробел в домене)
        };
        
        for (String email : emails) {
            System.out.println(email + " → " + (isValidEmail(email) ? "✓" : "✗"));
        }
    }
}

Разбор regex пошагово

Давайте разберём каждый компонент нашего regex:

^[a-zA-Z0-9]          — строка должна НАЧИНАТЬСЯ с буквы или цифры
[a-zA-Z0-9._%+-]*     — за ней могут идти буквы, цифры, точки, подчёркивания,
                         проценты, плюсы, дефисы (ноль или более)
@                     — буквально символ @
[a-zA-Z0-9]           — домен должен НАЧИНАТЬСЯ с буквы или цифры
[a-zA-Z0-9.-]*        — за ней могут идти буквы, цифры, точки, дефисы
\\.                   — буквальная точка (экранирована!)
[a-zA-Z]{2,63}        — расширение: от 2 до 63 букв
$                     — строка должна ЗАКАНЧИВАТЬСЯ здесь

Почему мы НЕ используем сложный regex? Существуют regex для email длиной в 10+ строк, которые пытаются покрыть ВСЕ стандарты. Но на практике лучше использовать простой regex + дополнительную проверку (например, попробовать отправить письмо). Слишком сложные regex могут быть неточными и медленными.

Извлечение компонентов email

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class EmailParser {
    
    private static final Pattern EMAIL_PARSE = Pattern.compile(
        "^([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+)\\.([a-zA-Z]{2,63})$"
    );
    
    public static void parseEmail(String email) {
        Matcher matcher = EMAIL_PARSE.matcher(email);
        
        if (matcher.matches()) {
            String localPart = matcher.group(1);
            String domain = matcher.group(2);
            String extension = matcher.group(3);
            
            System.out.println("Email: " + email);
            System.out.println("  Локальная часть: " + localPart);
            System.out.println("  Домен: " + domain);
            System.out.println("  Расширение: " + extension);
            System.out.println();
        } else {
            System.out.println("Невалидный email: " + email);
        }
    }
    
    public static void main(String[] args) {
        parseEmail("user@example.com");
        parseEmail("ivan.petrov@mail.ru");
        parseEmail("support@google.com");
        parseEmail("invalid-email");
    }
}

Практический пример: валидация телефонных номеров

Телефонные номера записываются по-разному: +7(999)123-45-67, 8 999 1234567, +7-999-123-45-67, 8(999)1234567. Наша задача — распознать все эти форматы. Это отличная демонстрация того, как regex справляется с реальными задачами.

Анализ форматов телефонов

Российские номера обычно начинаются с +7 или 8, затем 10 цифр. Разделители могут быть любыми: пробелы, дефисы, скобки.

Создаём regex для телефона

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class PhoneValidator {
    
    // Паттерн для российских номеров
    private static final Pattern PHONE_PATTERN = Pattern.compile(
        "^" +                                    // начало строки
        "(\\+7|8)" +                            // код страны: +7 или 8
        "[\\s\\-\\(]*" +                        // возможные разделители
        "(\\d{3})" +                            // код города/оператора (3 цифры)
        "[\\s\\-\\()]*" +                       // возможные разделители
        "(\\d{3})" +                            // первые 3 цифры номера
        "[\\s\\-]*" +                           // возможные разделители
        "(\\d{2})" +                            // следующие 2 цифры
        "[\\s\\-]*" +                           // возможные разделители
        "(\\d{2})" +                            // последние 2 цифры
        "$"                                     // конец строки
    );
    
    public static boolean isValidPhone(String phone) {
        return PHONE_PATTERN.matcher(phone).matches();
    }
    
    public static void parsePhone(String phone) {
        Matcher matcher = PHONE_PATTERN.matcher(phone);
        
        if (matcher.matches()) {
            String countryCode = matcher.group(1);
            String regionCode = matcher.group(2);
            String part1 = matcher.group(3);
            String part2 = matcher.group(4);
            String part3 = matcher.group(5);
            
            String formatted = String.format(
                "%s (%s) %s-%s-%s",
                countryCode, regionCode, part1, part2, part3
            );
            
            System.out.println("Исходный: " + phone);
            System.out.println("Форматированный: " + formatted);
            System.out.println();
        } else {
            System.out.println("Невалидный номер: " + phone);
        }
    }
    
    public static void main(String[] args) {
        String[] phones = {
            "+7(999)123-45-67",         // валидный
            "8 999 123 45 67",          // валидный
            "+7-999-123-45-67",         // валидный
            "8(999)1234567",            // валидный
            "+79991234567",             // валидный (без разделителей)
            "7-999-123-45-67",          // невалидный (нет +)
            "+7(99)123-45-67",          // невалидный (2 цифры в коде)
            "+7(999)12-45-67",          // невалидный (2 цифры в части)
        };
        
        for (String phone : phones) {
            System.out.println("Телефон: «" + phone + "» → " + 
                (isValidPhone(phone) ? "валиден" : "невалиден"));
        }
        
        System.out.println("\n--- Форматирование ---\n");
        for (String phone : phones) {
            if (isValidPhone(phone)) {
                parsePhone(phone);
            }
        }
    }
}

Извлечение всех телефонов из текста

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class PhoneExtractor {
    
    private static final Pattern PHONE_EXTRACT = Pattern.compile(
        "(?:\\+7|8)[\\s\\-\\(]*\\d{3}[\\s\\-\\()]*\\d{3}[\\s\\-]*\\d{2}[\\s\\-]*\\d{2}"
    );
    
    public static void main(String[] args) {
        String text = "Свяжитесь с нами: +7(495)123-45-67 или 8-800-555-35-35. " +
                      "Также доступен номер 8(916)987-65-43.";
        
        Matcher matcher = PHONE_EXTRACT.matcher(text);
        
        System.out.println("Найденные телефоны:");
        while (matcher.find()) {
            System.out.println("  " + matcher.group() + 
                " (позиция " + matcher.start() + "-" + matcher.end() + ")");
        }
    }
}

Дополнительные полезные конструкции

Опережающая (lookahead) и отстающая (lookbehind) проверки

Это «проверки условия» — они проверяют, что после/перед совпадением идёт (или не идёт) определённая последовательность, но НЕ включают её в результат:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class LookaroundExample {
    public static void main(String[] args) {
        String text = "Цена: 100$ или 200€ или 300₽";
        
        // Positive lookahead: найти числа, за которыми СЛЕДУЕТ "$"
        Pattern dollars = Pattern.compile("\\d+(?=\\$)");
        Matcher m1 = dollars.matcher(text);
        while (m1.find()) {
            System.out.println("В долларах: " + m1.group());
        }
        
        System.out.println("---");
        
        // Negative lookahead: найти числа, за которыми НЕ СЛЕДУЕТ "€"
        Pattern notEuros = Pattern.compile("\\d+(?!€)");
        Matcher m2 = notEuros.matcher(text);
        while (m2.find()) {
            System.out.println("Не в евро: " + m2.group());
        }
        
        System.out.println("---");
        
        // Positive lookbehind: найти числа, перед КОТОРЫМИ стоит "€"
        Pattern afterEuro = Pattern.compile("(?<=€)\\d+");
        Matcher m3 = afterEuro.matcher(text);
        while (m3.find()) {
            System.out.println("После евро: " + m3.group());
        }
    }
}

Оператор альтернации (|)

Вертикальная черта | означает «ИЛИ». Совпадёт левая ИЛИ правая часть:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class AlternationExample {
    public static void main(String[] args) {
        String text = "Java, JavaScript, Python, C++, C#";
        
        // Найти "Java" или "C++" или "C#"
        Pattern pattern = Pattern.compile("Java|C\\+\\+|C#");
        Matcher matcher = pattern.matcher(text);
        
        while (matcher.find()) {
            System.out.println("Найден язык: " + matcher.group());
        }
        
        System.out.println("---");
        
        // Скобки для группировки альтернации
        Pattern pattern2 = Pattern.compile("(Java|C)(Script|\\+\\+|#)");
        Matcher matcher2 = pattern.matcher(text);
        
        while (matcher2.find()) {
            System.out.println("Язык: " + matcher2.group(1) + ", модификация: " + matcher2.group(2));
        }
    }
}

Флаги встраивания

Вместо того чтобы передавать флаги в Pattern.compile(), можно встроить их прямо в regex:

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class InlineFlags {
    public static void main(String[] args) {
        // (?i) — case insensitive
        Pattern pattern1 = Pattern.compile("(?i)привет");
        System.out.println(pattern1.matcher("ПРИВЕТ").matches());  // true
        
        // (?m) — multiline
        String text = "Строка 1\nСтрока 2";
        Pattern pattern2 = Pattern.compile("(?m)^Строка 2$");
        System.out.println(pattern2.matcher(text).find());  // true
        
        // (?s) — dotall
        String text2 = "Привет\nмир";
        Pattern pattern3 = Pattern.compile("(?s)Привет.*мир");
        System.out.println(pattern3.matcher(text2).matches());  // true
    }
}

Итоги урока

  • ✓ Регулярные выражения — это шаблоны для поиска и проверки текста, гораздо мощнее простого поиска по строке
  • ✓ Класс Pattern компилирует regex, а Matcher применяет его к строкам
  • ✓ Метод find() ищет подстроки, matches() проверяет всю строку
  • ✓ Основные конструкции: . (любой символ), [] (набор символов), ^/$ (начало/конец строки)
  • ✓ Классы символов: \d (цифры), \w (буквы+цифры), \s (пробелы) — и их инверсии \D, \W, \S
  • ✓ Квантификаторы: * (ноль+), + (один+), ? (ноль/один), {n,m} (от n до m). Добавьте ? для ленивого варианта
  • ✓ Группы () захватывают части совпадения для дальнейшего использования
  • ✓ Методы String matches(), split(), replaceAll() удобны для простых операций
  • ✓ В реальных проектах regex используют для валидации данных, поиска информации, замены текста и парсинга

📖 Следующий урок: Мы продолжим изучение регулярных выражений и разберём более сложные паттерны, оптимизацию производительности и работу с Unicode. А также рассмотрим реальные кейсы использования regex вEnterprise-приложениях.

Тест: Регулярные выражения

10 вопросов

Валидация email

Premium