🐹 Go

Expressões regulares em Go (Golang): um guia detalhado

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

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

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

  • Поиска — найти все email-адреса в тексте
  • Проверки — убедиться, что пользователь ввёл правильный номер телефона
  • Замены — заменить все даты из формата "2024-01-15" на "15.01.2024"
  • Разделения — разбить строку на части по сложному разделителю

В Go (Golang) работа с регулярными выражениями вынесена в стандартный пакет regexp. Это значит, что вам не нужно устанавливать никаких дополнительных библиотек — всё уже есть в языке.

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

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

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

Основные строительные блоки

Символ Что означает Пример Найдёт
. Любой одиночный символ (кроме перевода строки) к.т "кот", "кит", "к?т"
* Предыдущий символ 0 или более раз ко*т "кт", "кот", "коот", "кооот"
+ Предыдущий символ 1 или более раз ко+т "кот", "коот", но НЕ "кт"
? Предыдущий символ 0 или 1 раз ко?т "кт" и "кот"
^ Начало строки ^Привет "Привет" только в начале строки
$ Конец строки конец$ "конец" только в конце строки
[] Набор символов (любой из перечисленных) [км]от "кот" и "мот"
[^] Отрицание набора [^к]от "рот", "бот", но НЕ "кот"
| ИЛИ (логическое) кот|пёс "кот" или "пёс"
() Группировка (ко)+т "кот", "кокот", "кококот"

Специальные классы символов

Класс Эквивалент Описание
d [0-9] Любая цифра
D [^0-9] Всё, кроме цифр
w [a-zA-Z0-9_] Любая буква, цифра или подчёркивание
W [^a-zA-Z0-9_] Всё, кроме букв, цифр и подчёркивания
s [ f] Любой пробельный символ
S [^ f] Всё, кроме пробельных символов

Как Go обрабатывает регулярные выражения

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

Запомните: В Go регулярное выражение сначала компилируется (превращается в понятный машине код), а потом уже применяется к тексту. Компиляция — дорогая операция, поэтому старайтесь компилировать выражение один раз и использовать его многократно.

Синтаксис в Go (Golang): все формы и варианты

Пакет regexp предоставляет два основных способа работы: функции-утилиты (для простых случаев) и методы скомпилированного объекта (для производительной работы).

Функции верхнего уровня (для одноразового использования)

Функция Что делает Пример
regexp.MatchString(pattern, s) Проверяет, есть ли в строке s хотя бы одно совпадение с шаблоном regexp.MatchString(`d+`, "abc123") → true
regexp.Match(pattern, b) То же самое, но для среза байтов regexp.Match(`d+`, []byte("abc123"))
regexp.QuoteMeta(s) Экранирует все спецсимволы в строке regexp.QuoteMeta("1+1=2") → "1\+1=2"

Методы скомпилированного регулярного выражения

Сначала компилируем:

// MustCompile паникует при ошибке (удобно для констант)
re := regexp.MustCompile(`d+`)

// Compile возвращает ошибку (безопасный вариант)
re, err := regexp.Compile(`d+`)
if err != nil {
    log.Fatal(err)
}

Теперь у нас есть объект re, с которым можно работать:

Метод Что возвращает Описание
re.MatchString(s) bool Есть ли совпадение в строке?
re.FindString(s) string Первое совпадение
re.FindAllString(s, n) []string Все совпадения (n — сколько найти, -1 — все)
re.FindStringIndex(s) []int Индексы первого совпадения [start, end]
re.FindStringSubmatch(s) []string Первое совпадение и все группы захвата
re.ReplaceAllString(s, repl) string Замена всех совпадений
re.Split(s, n) []string Разделение строки по шаблону
re.String() string Возвращает исходный шаблон

Флаги (модификаторы)

В Go флаги указываются в начале шаблона в скобках (?флаг):

Флаг Описание Пример
(?i) Игнорировать регистр (?i)go найдёт "Go", "GO", "go"
(?m) Многострочный режим (^ и $ работают для каждой строки) (?m)^Привет
(?s) Точка включает перевод строки (?s)привет.мир

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

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

package main

import (
    "fmt"
    "regexp"
)

func main() {
    // Создаём регулярное выражение: d — любая цифра, + — одна или более
    re := regexp.MustCompile(`d+`)
    
    // Проверяем строку
    text := "Привет, мне 25 лет!"
    result := re.MatchString(text)
    
    fmt.Println("Содержит цифры:", result) // Выведет: true
    
    // Проверяем строку без цифр
    text2 := "Привет, мир!"
    result2 := re.MatchString(text2)
    fmt.Println("Содержит цифры:", result2) // Выведет: false
}

Объяснение: d+ означает «одна или более цифр подряд». MatchString возвращает true, если нашёл хотя бы одно совпадение.

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

package main

import (
    "fmt"
    "regexp"
)

func main() {
    // Шаблон для email: буквы, цифры, точки, подчёркивания @ буквы.буквы
    // w — буква/цифра/подчёркивание, [w.] — добавляем точку
    // + — один или более раз, . — экранированная точка
    pattern := `[w.]+@[w.]+.w+`
    
    re := regexp.MustCompile(pattern)
    
    text := "Мои контакты: john@example.com и alice@mail.ru. Также есть support@my-site.org"
    
    // FindAllString ищет все совпадения, -1 означает "найти все"
    emails := re.FindAllString(text, -1)
    
    fmt.Println("Найденные email-адреса:")
    for i, email := range emails {
        fmt.Printf("%d. %s
", i+1, email)
    }
}

Объяснение: [w.]+ — это набор символов: буквы, цифры, подчёркивание и точка. Плюс означает, что их может быть много. . — это экранированная точка (просто точка, а не «любой символ»).

Пример 3: Извлечение данных с помощью групп захвата

package main

import (
    "fmt"
    "regexp"
)

func main() {
    // Шаблон для извлечения имени и возраста
    // (w+) — первая группа (имя), (d+) — вторая группа (возраст)
    pattern := `Меня зовут (w+), мне (d+) лет`
    
    re := regexp.MustCompile(pattern)
    
    text := "Меня зовут Иван, мне 25 лет"
    
    // FindStringSubmatch возвращает всё совпадение и группы
    matches := re.FindStringSubmatch(text)
    
    if len(matches) > 0 {
        fmt.Println("Полное совпадение:", matches[0])  // "Меня зовут Иван, мне 25 лет"
        fmt.Println("Имя:", matches[1])                 // "Иван"
        fmt.Println("Возраст:", matches[2])             // "25"
    }
}

Объяснение: Круглые скобки создают группы захвата. matches[0] — это всё найденное совпадение, matches[1] — первая группа, matches[2] — вторая.

Пример 4: Замена всех дат из формата ГГГГ-ММ-ДД в ДД.ММ.ГГГГ

package main

import (
    "fmt"
    "regexp"
)

func main() {
    // Шаблон: 4 цифры, дефис, 2 цифры, дефис, 2 цифры
    pattern := `(d{4})-(d{2})-(d{2})`
    
    re := regexp.MustCompile(pattern)
    
    text := "Сегодня 2024-01-15, а завтра 2024-01-16"
    
    // В замене $1, $2, $3 — это ссылки на группы захвата
    result := re.ReplaceAllString(text, "$3.$2.$1")
    
    fmt.Println("Было:", text)
    fmt.Println("Стало:", result)
    // Выведет: "Сегодня 15.01.2024, а завтра 16.01.2024"
}

Объяснение: $1, $2, $3 — это ссылки на захваченные группы. Мы переставляем их местами, чтобы получить нужный формат.

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

package main

import (
    "fmt"
    "regexp"
)

func checkPassword(password string) string {
    // Проверяем длину (минимум 8 символов)
    if len(password) < 8 {
        return "Слишком короткий пароль"
    }
    
    // Проверяем наличие хотя бы одной цифры
    hasDigit := regexp.MustCompile(`d`).MatchString(password)
    if !hasDigit {
        return "Пароль должен содержать хотя бы одну цифру"
    }
    
    // Проверяем наличие хотя бы одной заглавной буквы
    hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
    if !hasUpper {
        return "Пароль должен содержать хотя бы одну заглавную букву"
    }
    
    // Проверяем наличие хотя бы одной строчной буквы
    hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
    if !hasLower {
        return "Пароль должен содержать хотя бы одну строчную букву"
    }
    
    // Проверяем наличие спецсимволов
    hasSpecial := regexp.MustCompile(`[!@#$%^&*]`).MatchString(password)
    if !hasSpecial {
        return "Пароль должен содержать хотя бы один спецсимвол (!@#$%^&*)"
    }
    
    return "Пароль надёжный!"
}

func main() {
    passwords := []string{"123", "password", "Password1", "Pass1!", "SuperPass123!"}
    
    for _, pwd := range passwords {
        fmt.Printf("%s → %s
", pwd, checkPassword(pwd))
    }
}

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

Пример 6: Разделение строки по сложному разделителю

package main

import (
    "fmt"
    "regexp"
)

func main() {
    // Разделяем строку по знакам препинания и пробелам
    // [s,.;:!?]+ — один или более пробелов или знаков препинания
    pattern := `[s,.;:!?]+`
    
    re := regexp.MustCompile(pattern)
    
    text := "Привет, мир! Как дела? Всё хорошо; давай... до встречи."
    
    // Split разделяет строку, -1 означает "все части"
    words := re.Split(text, -1)
    
    fmt.Println("Исходный текст:", text)
    fmt.Println("Разделено на слова:")
    for i, word := range words {
        if word != "" { // Пропускаем пустые строки
            fmt.Printf("%d. %s
", i+1, word)
        }
    }
}

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

Пример 7: Поиск и подсветка всех HTML-тегов

package main

import (
    "fmt"
    "regexp"
)

func main() {
    // Шаблон для HTML-тегов: <...>
    // < — открывающая скобка
    // [^>]* — любые символы, кроме >
    // > — закрывающая скобка
    pattern := `<[^>]*>`
    
    re := regexp.MustCompile(pattern)
    
    html := `<div class="content">
        <h1>Заголовок</h1>
        <p>Текст с <b>жирным</b> словом</p>
    </div>`
    
    // Находим все теги
    tags := re.FindAllString(html, -1)
    
    fmt.Println("Найденные HTML-теги:")
    for i, tag := range tags {
        fmt.Printf("%d. %s
", i+1, tag)
    }
    
    // Подсчитываем количество тегов
    fmt.Printf("
Всего найдено тегов: %d
", len(tags))
    
    // Удаляем все теги (оставляем только текст)
    cleanText := re.ReplaceAllString(html, "")
    fmt.Println("
Текст без тегов:")
    fmt.Println(cleanText)
}

Объяснение: <[^>]*> — это классический шаблон для поиска HTML-тегов. [^>]* означает «ноль или более любых символов, кроме >». Это гарантирует, что мы не выйдем за пределы одного тега.

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

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

Характеристика Go (regexp) Python (re)
Движок RE2 (линейное время, нет обратных ссылок) PCRE (поддерживает обратные ссылки, но может зависнуть)
Компиляция Обязательна для многократного использования Автоматически кэширует последние 512 выражений
Синтаксис Почти идентичен Почти идентичен
Группы захвата $1, $2 в замене 1, 2 в замене
Именованные группы (?P<name>...) (?P<name>...)
Флаги Внутри шаблона: (?i) Параметром: re.IGNORECASE
Обратные ссылки НЕ поддерживаются Поддерживаются: 1
Lookahead/lookbehind НЕ поддерживаются Поддерживаются: (?=...), (?<=...)
Важное отличие: В Go нет lookahead и lookbehind. Если вам нужно проверить, что после совпадения идёт определённый символ, но не включать его в результат, придётся использовать группы захвата и обрабатывать результат вручную.

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

1. Забыли экранировать спецсимволы

// НЕПРАВИЛЬНО: точка означает "любой символ"
re := regexp.MustCompile(`google.com`)  // Найдёт "googleXcom"

// ПРАВИЛЬНО: экранируем точку
re := regexp.MustCompile(`google.com`) // Найдёт только "google.com"

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

text := "<div>текст1</div><div>текст2</div>"

// Жадный режим: * захватит как можно больше
reGreedy := regexp.MustCompile(`<div>.*</div>`)
fmt.Println(reGreedy.FindString(text))
// Выведет: "<div>текст1</div><div>текст2</div>" (всё целиком)

// Ленивый режим: *? захватит как можно меньше
reLazy := regexp.MustCompile(`<div>.*?</div>`)
fmt.Println(reLazy.FindString(text))
// Выведет: "<div>текст1</div>" (только первое совпадение)

3. Компиляция внутри цикла

// НЕПРАВИЛЬНО: компилируем на каждой итерации
for _, text := range texts {
    re := regexp.MustCompile(`d+`)  // ОЧЕНЬ МЕДЛЕННО!
    re.MatchString(text)
}

// ПРАВИЛЬНО: компилируем один раз
re := regexp.MustCompile(`d+`)
for _, text := range texts {
    re.MatchString(text)  // Быстро!
}

4. Неправильное использование якорей

// ^ и $ работают для ВСЕЙ строки, а не для каждой строки
text := "строка1
строка2
строка3"

// Найдёт только если "строка1" в самом начале
re := regexp.MustCompile(`^строка1`)
fmt.Println(re.FindString(text)) // "строка1"

// Чтобы искать в начале каждой строки, используем (?m)
reMulti := regexp.MustCompile(`(?m)^строка`)
fmt.Println(reMulti.FindAllString(text, -1)) // ["строка1", "строка2", "строка3"]

5. Путаница между MatchString и FindString

re := regexp.MustCompile(`abc`)

// MatchString проверяет, есть ли совпадение ГДЕ-УГОДНО в строке
fmt.Println(re.MatchString("xyzabcxyz")) // true

// FindString возвращает ПЕРВОЕ совпадение
fmt.Println(re.FindString("xyzabcxyz")) // "abc"

// MatchString НЕ проверяет, что строка целиком соответствует шаблону!
// Для полного соответствия используйте якоря:
reFull := regexp.MustCompile(`^abc$`)
fmt.Println(reFull.MatchString("abc"))    // true
fmt.Println(reFull.MatchString("xyzabc")) // false

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

1. Всегда используйте сырые строки

В Go сырые строки (в обратных кавычках) не обрабатывают escape-последовательности. Это делает регулярные выражения гораздо читаемее:

// Плохо: нужно удваивать обратные слеши
re := regexp.MustCompile("\d+\.\d+")

// Хорошо: сырая строка
re := regexp.MustCompile(`d+.d+`)

2. Компилируйте один раз, используйте много раз

// Создаём глобальную переменную для часто используемого выражения
var emailRegex = regexp.MustCompile(`^[w.]+@[w.]+.w+$`)

func isValidEmail(email string) bool {
    return emailRegex.MatchString(email)
}

3. Используйте MustCompile для констант

// Если выражение точно корректно (например, задано в коде)
var phoneRegex = regexp.MustCompile(`^+7d{10}$`)

// Если выражение приходит из пользовательского ввода
userPattern := getUserInput()
re, err := regexp.Compile(userPattern)
if err != nil {
    // Обрабатываем ошибку
    return
}

4. Тестируйте регулярные выражения

func TestEmailRegex(t *testing.T) {
    re := regexp.MustCompile(`^[w.]+@[w.]+.w+$`)
    
    tests := []struct {
        email string
        valid bool
    }{
        {"user@example.com", true},
        {"user.name@example.co.uk", true},
        {"@example.com", false},
        {"user@.com", false},
        {"user@example", false},
    }
    
    for _, tt := range tests {
        if re.MatchString(tt.email) != tt.valid {
            t.Errorf("Email %s: ожидалось %v", tt.email, tt.valid)
        }
    }
}

5. Не пытайтесь парсить HTML регулярками

Золотое правило: Регулярные выражения не подходят для парсинга HTML, XML или JSON. Используйте специальные парсеры. Регулярки хороши для простых текстовых шаблонов, но не для вложенных структур.

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

// Шаблон для российского номера телефона:
// ^ — начало строки
// +7 — код страны
// d{3} — код оператора (3 цифры)
// d{7} — номер (7 цифр)
// $ — конец строки
var phoneRegex = regexp.MustCompile(`^+7d{3}d{7}$`)

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

  1. Вопрос: Что вернёт regexp.MustCompile(`d+`).MatchString("abc")?

    Ответ: false, потому что в строке "abc" нет цифр.

  2. Вопрос: Чем отличается FindString от FindAllString?

    Ответ: FindString возвращает только первое совпадение, а FindAllString — все совпадения в виде среза строк.

  3. Вопрос: Что