Данна статья является переводм статьи Paul Hudson  How to use regular expressions in Swift

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

Регулярные выражения позволяют нам выполнять сложные операции поиска и замены в тысячах текстовых файлов всего за несколько секунд, поэтому неудивительно, что они популярны уже более 50 лет. Apple обеспечивает поддержку регулярных выражений на всех своих платформах – iOS, macOS, tvOS и даже watchOS – все они используют один и тот же класс, NSRegularExpression. Это чрезвычайно быстрый и эффективный способ поиска и замены сложного текста десятки тысяч раз, и все это доступно для использования разработчиками Swift.

В этой статье мы рассмотрим, как создавать регулярные выражения с помощью NSRegularExpression и как сопоставлять различные регулярные выражения, используя наиболее важный синтаксис.

Начнем с основ.

Давайте начнем с пары простых примеров для тех, кто раньше не использовал регулярные выражения. Регулярные выражения – сокращенно regexes – предназначены для того, чтобы мы могли выполнять нечеткий поиск внутри строк. Например, мы знаем, что "cat".contains("at") является истиной, но что, если мы захотим сопоставить любое трехбуквенное слово, оканчивающееся на "at"?

Это как раз то, для чего предназначены регулярные выражения.

Сначала определим строку, которую мы хотим проверить:

let testString = "hat"

Затем мы создаем экземпляр NSRange, который представляет полную длину строки:

let range = NSRange(location: 0, length: testString.utf16.count)

Используем значение utf16, чтобы избежать проблем с эмодзи и тому подобным. Затем мы создаем экземпляр NSRegularExpression, используя синтаксис регулярных выражений:

let regex = try! NSRegularExpression(pattern: "[a-z]at")

[a-z] - это способ регулярного выражения указать любую букву от “a” до “z”. Используем try!, потому что мы можем попытаться ввести недопустимое регулярное выражение. Но здесь мы жестко закодировали правильное регулярное выражение, так что нет необходимости пытаться отлавливать ошибки. Наконец, мы вызываем first Match(in:) для созданного регулярного выражения, передавая строку для поиска, специальные параметры и диапазон строки для поиска. Если наша строка соответствует регулярному выражению, то вызов метода вернет нам данные, в противном случае - nil. Так что, если мы просто хотим проверить, соответствует ли строка регулярному выражению, то сравним результат first Match(in:) с nil:

regex.firstMatch(in: testString, options: [], range: range) != nil

Регулярное выражение “[a-z]at” будет успешно соответствовать “hat”, а также “cat”, “sat”, “mat”, “bat” и так далее – мы фокусируемся на том, чему хотим соответствовать, а NSRegularExpression делает все остальное. Давайте постараемся упростить использование NSRegularExpression Чуть позже мы подробнее рассмотрим синтаксис регулярных выражений, но сначала давайте посмотрим, сможем ли мы сделать NSRegularExpression немного более дружественным. Прямо сейчас наш код использует три строки нетривиального Swift кода, чтобы соответствовать простой строке:

let range = NSRange(location: 0, length: testString.utf16.count)
let regex = try! NSRegularExpression(pattern: "[a-z]at")
regex.firstMatch(in: testString, options: [], range: range) != nil

Мы можем улучшить его различными способами, но, вероятно, наиболее разумным является расширение NSRegularExpression, чтобы упростить создание и сопоставление выражений. Во-первых, эта строка:

let regex = try! NSRegularExpression(pattern: "[a-z]at")

Как отмечалось ранее, создание экземпляра NSRegularExpression может привести к ошибкам, потому что мы можем попытаться предоставить недопустимое регулярное выражение – например [a-zat вызовет ошибку, потому что мы не закрыли ]. Однако большую часть времени наши регулярные выражения будут жестко запрограммированы на то что мы хотим найти, и они либо правильные, либо неправильные во время компиляции. В результате обычно создаются экземпляры NSRegularExpression с помощью try!. Однако это может привести к хаосу с инструментами линтинга, такими как SwiftLint, поэтому лучшей идеей будет создать удобный инициализатор, который либо правильно создает регулярное выражение, либо вызывает ошибку на этапе разработки:

extension NSRegularExpression {
    convenience init(_ pattern: String) {
        do {
            try self.init(pattern: pattern)
        } catch {
            preconditionFailure("Illegal regular expression: \(pattern).")
        }
    }
}

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

Во-вторых, эти строки:

let range = NSRange(location: 0, length: testString.utf16.count)
regex.firstMatch(in: testString, options: [], range: range) != nil

В первой создается NSRange, охватывающий всю нашу строку, а во второй ищется первое совпадение в нашей тестовой строке. 

Это не совсем удобно. Большую часть времени нам захочется выполнить поиск по всей входной строке, и использование firstMatch(in:) вместе с проверкой nil запутывает наши намерения.

Давайте заменим это вторым расширением, которое объединяет эти строки в один метод matches():

extension NSRegularExpression {
    func matches(_ string: String) -> Bool {
        let range = NSRange(location: 0, length: string.utf16.count)
        return firstMatch(in: string, options: [], range: range) != nil
    }
}

Если мы объединим эти два расширения, то теперь сможем создавать и проверять регулярные выражения гораздо более естественным образом:

let regex = NSRegularExpression("[a-z]at")
regex.matches("hat")

Мы могли бы пойти дальше, используя перегрузку операторов, чтобы заставить оператор contains в Swift, ~=, работать с регулярными выражениями:

extension String {
    static func ~= (lhs: String, rhs: String) -> Bool {
        guard let regex = try? NSRegularExpression(pattern: rhs) else { return false }
        let range = NSRange(location: 0, length: lhs.utf16.count)
        return regex.firstMatch(in: lhs, options: [], range: range) != nil
    }
}

Этот код позволяет нам использовать любую строку слева и регулярное выражение справа:

"hat" ~= "[a-z]at"

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

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

Мы уже использовали [a-z] для обозначения “любой буквы от “a” до “z”, и в терминах регулярных выражений это класс символов. Это позволяет нам указать группу букв, которые должны быть сопоставлены, либо путем конкретного перечисления каждой из них, либо с помощью диапазона символов.

Диапазоны регулярных выражений не обязательно должны содержать полный алфавит. Мы можем использовать [a-t], чтобы исключить буквы от “u” до “z”. С другой стороны, если вы хотите быть конкретным в отношении букв в классе, просто перечислите их по отдельности следующим образом:

[csm]at

Регулярные выражения по умолчанию чувствительны к регистру, что означает, что “Cat” и “Mat” не будут совпадать с “[a-z]at”. Если мы хотим игнорировать регистр букв, то мы можем либо использовать “[a-zA-Z]at”, либо создать свой объект NSRegularExpression с флагом .caseInsensitive.

Помимо диапазонов прописных и строчных букв, мы также можем указать диапазоны цифр с помощью классов символов. Чаще всего это [0-9], чтобы разрешить любое число, или [A-Za-z0-9], чтобы разрешить любую буквенно-цифровую букву, но мы также можем использовать [A-Fa-f0-9], например, для сопоставления шестнадцатеричных чисел.

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

Одним из наиболее распространенных является квантор со звездочкой *, что означает “ноль или более совпадений”. Кванторы всегда появляются после того, что они изменяют, поэтому мы могли бы написать что-то вроде этого:

let regex = NSRegularExpression("ca[a-z]*d")

Это выражение ищет “ca”, затем ноль или более символов от “a” до “z”, затем “d” – это соответствует “cad”, “card”, “camped” и многим другим.

Помимо *, существуют два других аналогичных квантора: + и ?. Если мы используем +, это означает “один или более”, что немного отличается от “нуля или более” в *. А если мы используем „?“ это означает “ноль или единица”.

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

c[a-z]*d
c[a-z]+d
c[a-z]?d

Давайте посмотрим на каждое из этих трех регулярных выражений, а затем подумаем вот о чем: когда задана тестовая строка “cd”, чему будет соответствовать каждое из этих трех? А как насчет того, когда задана тестовая строка ”camped"?

Первое регулярное выражение c[a-z]*d означает „c“, затем ноль или более строчных букв, затем „d”, поэтому оно будет соответствовать как “cd”, так и “camped”.

Второе регулярное выражение c[a-z]+d означает „c“, затем одна или несколько строчных букв, затем „d”, поэтому оно не будет соответствовать “cd”, но будет соответствовать “camped”.

Наконец, третье регулярное выражение c[a-z]?d означает „c“, затем ноль или одна строчная буква, затем „d”, так что оно будет соответствовать “cd”, но не “camped”.

Квантификаторы не ограничены только классами символов. Например, если мы хотим сопоставить слово “color” как в американском английском (“color”), так и в британском английском (“colour”), мы могли бы использовать регулярное выражение colou?r. То есть “сопоставьте точную строку ‘colo’, совпадающую с нулем или одной ‘u‘, затем с ’r'”.

Мы также можем более конкретно указать количество: “Я хочу, чтобы совпадали ровно три символа”. Это делается с помощью фигурных скобок, { и }. Например, [a-z]{3} означает “соответствовать ровно трем строчным буквам”.

Рассмотрим телефонный номер, отформатированный следующим образом: 111-1111. Мы хотим соответствовать только этому формату, а не “11-11”, “1111-111” или “11111111111”, что означает, что регулярного выражения типа [0-9-]+ было бы недостаточно. Вместо этого нам нужно регулярное выражение, подобное этому: [0-9]{3}-[0-9]{4}: ровно три цифры, затем тире, затем ровно четыре цифры.

Мы также можем использовать фигурные скобки для указания диапазонов, как ограниченных, так и неограниченных. Например, [a-z]{1,3} означает “совпадение с одной, двумя или тремя строчными буквами”, а [a-z]{3,} означает “совпадение по крайней мере с тремя, но потенциально с любым числом больше”.

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

Во-первых, наиболее часто используемым и злоупотребляемым из них является . символ – точка – который будет соответствовать любому отдельному символу, за исключением разрыва строки. Таким образом, регулярное выражение c.t будет соответствовать “cat”, но не “cart”. Если вы используете . с квантором * это означает “сопоставить одно или несколько значений, которые не являются переносом строки”, что, вероятно, является наиболее распространенным регулярным выражением, с которым мы можем столкнуться.

Причину его использования должно быть легко распознать: не нужно беспокоиться о создании определенного регулярного выражения, потому что .* будет соответствовать практически всему. Проблема в том, что специфичность – это своего рода смысл регулярных выражений: мы можем искать точные варианты текста, чтобы применить некоторую обработку. Слишком многие люди полагаются на .* в качестве опоры, не осознавая, что это может привести к незначительным ошибкам в их выражениях.

В качестве примера рассмотрим регулярное выражение, которое мы написали для сопоставления телефонных номеров, например 555-5555: [0-9]{3}-[0-9]{4}. Мы можем подумать: “Может быть, некоторые люди напишут “555 5555” или “5555555”, и попытаются сделать ваше регулярное выражение более свободным, используя .* вместо этого, вот так: [0-9]{3}.[0-9]{4}.

Но теперь у нас проблема: это будет соответствовать “123-4567”, “123-4567890” и даже “123-456-789012345”. В первом случае . будет соответствовать “-“; во втором он будет соответствовать “-456“; а в третьем он будет соответствовать “-456-78901” – то что потребуется для [0-9]{3} и [0-9]{4}.

Вместо этого мы можем использовать символьные классы с квантификаторами, например [0-9]{3}[ -]*[0-9]{4} означает “найдите три цифры, затем ноль или более пробелов и тире, затем четыре цифры”. 

Мы также можем использовать классы инвертированных символов для сопоставления со всем, что не является цифрой, так [^0-9] будет соответствовать пробелу, тире, косой черте и многому другому, но не будет соответствовать числам.

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

Чтобы увидеть, что меняется, давайте начнем с простого и будем продвигаться вперед.

Во-первых, теперь мы можем использовать целую кучу новых строковых методов, например, так:

let message = "the cat sat on the mat"
print(message.ranges(of: "at"))
print(message.replacing("cat", with: "dog"))
print(message.trimmingPrefix("the "))

Но реальная сила этих методов заключается в том, что все они также принимают регулярные выражения:

print(message.ranges(of: /[a-z]at/))
print(message.replacing(/[a-m]at/, with: "dog"))
print(message.trimmingPrefix(/The/.ignoresCase()))

В первом регулярном выражении мы запрашиваем диапазон всех подстрок, которые соответствуют любой букве алфавита в нижнем регистре, за которой следует “at”, чтобы найти местоположения “cat”, “sat” и “mat”.

Во втором примере мы сопоставляем только диапазон от “a” до “m”, поэтому будет напечатано “the dog sat on the dog”.

В третьем мы ищем “The”, но мы изменили регулярное выражение, чтобы оно было нечувствительным к регистру, чтобы оно соответствовало “the”, “THE” и так далее.

Обратите внимание, как каждое из этих регулярных выражений создается с использованием литералов регулярных выражений – возможность создавать регулярное выражение, начиная и заканчивая ваше регулярное выражение символом /.

Наряду с литералами регулярных выражений Swift предоставляет специальный тип Regex, который работает аналогично:

do {
    let atSearch = try Regex("[a-z]at")
    print(message.ranges(of: atSearch))
} catch {
    print("Failed to create regex")
}

Однако есть ключевое отличие, которое имеет существенные побочные эффекты для нашего кода: когда мы создаем регулярное выражение из строки с помощью Regex, Swift должен парсиьб строку во время выполнения, чтобы определить фактическое выражение, которое он должен использовать. 

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

Это стоит повторить, потому что это весьма примечательно: Swift анализирует наши регулярные выражения во время компиляции, проверяя их корректность.

Чтобы увидеть, насколько сильно это различие, рассмотрим этот код:

let search1 = /My name is (.+?) and I'm (\d+) years old./
let greeting1 = "My name is Taylor and I'm 26 years old."

if let result = try? search1.wholeMatch(in: greeting1) {
    print("Name: \(result.1)")
    print("Age: \(result.2)")
}

Это создает регулярное выражение, ищущее два конкретных значения в некотором тексте, и если оно находит их оба, печатает их. Но обратите внимание, что результирующий кортеж может ссылаться на свои совпадения как .1 и .2, потому что Swift точно знает, какие совпадения произойдут. (На случай, если вам интересно, .0 вернет всю совпадающую строку целиком.)

На самом деле, мы можем пойти еще дальше, потому что регулярные выражения позволяют нам присваивать имена нашим совпадениям, и они передаются в результирующий кортеж совпадений:

let search2 = /My name is (?<name>.+?) and I'm (?<age>\d+) years old./
let greeting2 = "My name is Taylor and I'm 26 years old."

if let result = try? search2.wholeMatch(in: greeting2) {
    print("Name: \(result.name)")
    print("Age: \(result.age)")
}

Такой вид безопасности просто был бы невозможен с регулярными выражениями, созданными из строк.

Но Swift идет на шаг дальше: мы можем создавать регулярные выражения из строк, мы можем создавать их из литералов регулярных выражений, но мы также можем создавать их из языка, зависящего от предметной области, аналогичного коду SwiftUI.

Например, если бы мы хотели сопоставить тот же текст “My name is Taylor and I’m 26 years old”, мы могли бы написать регулярное выражение следующим образом:

import RegexBuilder

let search3 = Regex {
    "My name is "

    Capture {
        OneOrMore(.word)
    }

    " and I'm "

    Capture {
        OneOrMore(.digit)
    }

    " years old."
}

Еще лучше то, что этот DSL подход способен применять преобразования к найденным совпадениям, и если мы используем TryCapture, а не Capture, то Swift автоматически посчитает, что все регулярное выражение не соответствует, если Capture завершится неудачно или выдаст ошибку. Итак, в случае нашего совпадения по возрасту мы могли бы написать следующее, чтобы преобразовать строку возраста в целое число:

let search4 = Regex {
    "My name is "

    Capture {
        OneOrMore(.word)
    }

    " and I'm "

    TryCapture {
        OneOrMore(.digit)
    } transform: { match in
        Int(match)
    }

    " years old."
}

И мы даже можем объединить именованные совпадения, используя переменные с определенными типами, подобными этому:

let nameRef = Reference(Substring.self)
let ageRef = Reference(Int.self)

let search5 = Regex {
    "My name is "

    Capture(as: nameRef) {
        OneOrMore(.word)
    }

    " and I'm "

    TryCapture(as: ageRef) {
        OneOrMore(.digit)
    } transform: { match in
        Int(match)
    }

    " years old."
}

if let result = greeting1.firstMatch(of: search5) {
    print("Name: \(result[nameRef])")
    print("Age: \(result[ageRef])")
}

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

Комментарии (0)