$ sudo teach IT

Зачем регулярные выражения

Иногда задача не «разобрать данные по полям» (для этого хватает Codable), а «проверить, похож ли текст на нужный формат» или «вытащить кусок текста по шаблону». Например: подходит ли строка под формат номера телефона, есть ли в тексте цена, как достать имя пользователя из адреса почты. Писать это через hasPrefix, split и ручные циклы по символам можно, но получится длинно и хрупко: любое усложнение формата — переписывать всё заново.

Регулярное выражение — это короткая запись самого шаблона: «две заглавные буквы, потом дефис, потом четыре цифры». Один раз описываете шаблон — и дальше проверяете и разбираете им любые строки. Официальный термин — Regex, так эта возможность называется в документации Apple и в Swift Evolution.

Как в Swift устроен Regex

В Swift есть тип Regex, обобщённый по тому, что именно он находит: просто факт совпадения, совпадение целиком, совпадение вместе с захваченными группами. Самый простой способ создать Regex — литерал прямо в коде, обрамлённый косыми чертами:

let pattern = /[A-Z]{2}-[0-9]{4}/

Такой литерал компилятор разбирает и проверяет на этапе сборки — опечатка в шаблоне станет ошибкой компиляции, а не сюрпризом в рантайме. Тип pattern здесь выводится автоматически как Regex<(Substring, Substring)> или проще, в зависимости от того, есть ли в шаблоне группы захвата — компилятор сам вычисляет, что именно вернёт совпадение.

Синтаксис самого шаблона внутри косых черт — отдельный маленький язык:

ЗаписьЧто значит
\dодна цифра
[A-Z]один символ из диапазона (здесь — заглавная латинская буква)
{4}ровно 4 повторения предыдущего элемента
+одно или больше повторений
(...)группа захвата — то, что можно потом отдельно достать

wholeMatch: подходит ли строка целиком

Метод wholeMatch(of:) отвечает на вопрос «строка ЦЕЛИКОМ соответствует шаблону?». Если да — возвращает совпадение, если нет (даже если шаблон нашёлся только в середине) — nil.

func isCode(_ text: String) -> Bool {
    return text.wholeMatch(of: /[A-Z]{2}-[0-9]{4}/) != nil
}

print(isCode("AB-1234"))   // true, строка целиком подходит
print(isCode("xxAB-1234")) // false, есть лишние символы перед кодом

Каждое обращение к /[A-Z]{2}-[0-9]{4}/ компилятор трактует как один и тот же уже разобранный шаблон — можно вынести его в константу и переиспользовать, если он нужен в нескольких местах.

firstMatch: найти шаблон где угодно в строке

firstMatch(of:) ищет первое место, где шаблон подходит, и не требует, чтобы шаблон описывал всю строку. Возвращает опциональное совпадение — тоже nil, если ничего не нашлось.

if let match = "заказ №482 оформлен".firstMatch(of: /[0-9]+/) {
    print(match.0) // 482
}

match.0 — это всегда совпадение целиком. Если в шаблоне есть группы в скобках, они доступны по следующим номерам: match.1, match.2 и так далее, в порядке открывающих скобок. Компилятор знает про эти номера ещё на этапе сборки, поэтому обращение к несуществующей группе — это ошибка компиляции, а не крах во время выполнения.

matches(of:): все совпадения сразу

Когда подходящих мест в строке несколько, нужен matches(of:) — он возвращает массив всех непересекающихся совпадений по порядку.

let receipt = "Хлеб: 80р., Молоко: 120р."

for match in receipt.matches(of: /([0-9]+)р\./) {
    print(match.1)
}
// печатает: 80, затем 120

Обратите внимание на \. внутри шаблона: точка в регулярных выражениях по умолчанию означает «любой символ», а нам нужна именно точка. Обратный слэш перед ней убирает это особое значение. Группа ([0-9]+) в скобках доступна как match.1 — это Substring, который при необходимости превращают в число через Int(...).

RegexBuilder: тот же шаблон, но кодом

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

import RegexBuilder

let priceBuilder = Regex {
    Capture {
        OneOrMore(.digit)
    }
    "р."
}
// эквивалентно /([0-9]+)р\./, но читается по шагам

Capture здесь заменяет скобки-группу, а OneOrMore(.digit) — запись [0-9]+. Для сложных шаблонов с несколькими группами такой код читать и поддерживать проще, чем строку с десятком спецсимволов. В задачах этого урока RegexBuilder не понадобится, но полезно знать, что он есть, если шаблон в проекте разрастётся.

Regex из строки: когда шаблон известен только во время выполнения

Литерал /pattern/ подходит, когда шаблон известен заранее и записан прямо в коде. Если же шаблон приходит откуда-то извне — например, его вводит пользователь или он лежит в конфигурации, — литерал не годится: его нельзя собрать из произвольной строки во время выполнения программы. Для этого случая есть отдельный инициализатор, принимающий обычную строку:

let runtimePattern = "[0-9]+"
let regex = try! Regex(runtimePattern)

Такой Regex разбирается уже во время выполнения, поэтому инициализатор помечен throws — в строке может оказаться некорректный шаблон. У совпадений, полученных через такой Regex, группы придётся доставать иначе, по индексу через output, а не как match.1: компилятор заранее не знает, сколько групп будет в строке, которая появится только в рантайме. В задачах этого урока шаблон известен заранее, поэтому используется литерал.

Частые ошибки

Литерал требует особого режима сборки. Синтаксис /pattern/ компилятор включает не всегда: при прямой сборке одного файла флагом swiftc ему нужен явный флаг -enable-bare-slash-regex. В песочнице этого курса он включён, поэтому литералы здесь работают без оговорок. А вот в своём собственном проекте, собираемом через Swift Package Manager (обычный способ работы с Xcode и swift build), литералы доступны по умолчанию — SwiftPM сам передаёт нужный флаг для достаточно новой версии инструментов.

Путаница между wholeMatch и firstMatch. firstMatch(of:) находит шаблон в любом месте строки — если шаблон описывает только часть требований, лишние символы вокруг совпадения проходят незамеченными. Для проверки формата целиком (код, логин, номер) нужен именно wholeMatch(of:).

Путаница в нумерации групп. match.0 — это всегда совпадение целиком, а не первая группа в скобках. Первая группа — это match.1, вторая — match.2. Ошибиться на единицу здесь очень легко.

Забыли экранировать спецсимвол. Символы ., +, (, ) и другие в регулярных выражениях имеют особое значение. Если нужен именно этот символ буквально, перед ним ставится обратный слэш: \., \+, \(.

Резюме

  • Regex описывает шаблон текста один раз, а не разбирается вручную циклами и split
  • литерал /pattern/ компилятор проверяет уже на этапе сборки; в песочнице курса для этого включён флаг -enable-bare-slash-regex, в проекте на SwiftPM он работает без дополнительных настроек
  • wholeMatch(of:) — строка должна подходить под шаблон целиком
  • firstMatch(of:) — первое совпадение в любом месте строки
  • matches(of:) — все совпадения сразу, массивом
  • у совпадения от литерала группы в скобках достаются по номеру: match.0 — совпадение целиком, match.1, match.2 — группы по порядку
  • если шаблон известен только во время выполнения (пришёл строкой снаружи), вместо литерала используют try! Regex(строка)
  • RegexBuilder собирает тот же Regex из именованных блоков вместо строки, для сложных шаблонов читается легче

Проверьте себя

3 вопроса

Проверка кода накладной

Отдел закупок хранит коды накладных в строгом формате: две заглавные латинские буквы, дефис, ровно четыре цифры. Пример подходящей строки — AB-1234.

Напишите функцию isValidInvoiceCode, которая принимает строку и возвращает true, если строка ЦЕЛИКОМ соответствует этому формату, и false в любом другом случае: строчные буквы, другое количество цифр, отсутствие дефиса или лишние символы вокруг кода — всё это должно давать false.

Суммы в чеке

В текстовом чеке цены записаны вперемешку с названиями товаров, например: Хлеб: 80р., Молоко: 120р.

Напишите функцию extractPrices(from:), которая принимает произвольный текст и возвращает массив Int — все числа, сразу после которых в тексте идёт р., в том порядке, в котором они встретились. Если в тексте нет ни одного такого числа, верните пустой массив.