Зачем регулярные выражения
Иногда задача не «разобрать данные по полям» (для этого хватает 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 — все числа, сразу после которых в тексте идёт р., в том порядке, в котором они встретились. Если в тексте нет ни одного такого числа, верните пустой массив.