Swift 5.7 中引入了正则表达式的语法支持,整理一下相关的一些话题、方法和示例,以备今后自己能够速查。

总览

Swift 正则由标准库中的 Regex 类型驱动,需要 iOS 16.0 或 macOS 13.0,早期的 deploy 版本无法使用。

构建一个正则表达式的方式,分为传统的正则字面量构建,以及通过 Regex Builder DSL 的更加易读的方式。后者可以内嵌使用前者,以及其他一些已有的 parser,在可读性和功能上要强力很多。实践中,推荐 结合使用字面量和 Builder API 在简洁和易读之间获取平衡

常见字面量

和其他各语言正则表达式的字面量没有显著不同。

直接将字面量包裹在 /.../ 中使用,Swift 将把类似的声明转换为 Regex 类型的实例:

1
let bitcoinAddress_v1 = /([13][a-km-zA-HJ-NP-Z0-9]{26,33})/

一些常用的字面量表达以及示例。更多非常用的例子,可以参考 这里的 Cheat Sheet

字符集

表达式 说明 示例
[aeiou] 匹配指定字符集 On e V’s␣D e n␣ i s␣ a ␣bl o g.
[^aeiou] 排除字符集 On e V’s␣D e n␣ i s␣ a ␣bl o g.
[A-Z] 匹配字符范围 O ne V ’s␣ D en␣is␣a␣blog.
. 除换行符以外的任意字符。等效于 [^\n\r] OneV’s␣Den␣is␣a␣blog.
\s 匹配空格字符 (包括 tab 和换行) OneV’s Den is a blog.
\S 匹配非空格字符 OneV’s Den is a blog.
[\s\S] 匹配空格和非空格,也即任意字符。等效于 [^] OneV’s␣Den␣is␣a␣blog.
\w 匹配字母数字下划线等低位 ASCII。等效于 [A-Za-z0-9_] OneV s Den is a blog .
\W 等效于 [^A-Za-z0-9_]
\d 匹配数字,等效于 [0-9] +( 81 ) 021 - 1234 - 5678
\D 非数字,等效于 [^0-9] +( 81 ) 021 - 1234 - 5678

数量

表达式 说明 示例 结果
+ 匹配一个或多个 b\w+ b be bee beer beers
* 匹配零个或多个 b\w* b be bee beer beers
{2,3} 匹配若干个 b\w{2,3} b be bee beer beer s
? 匹配零个或一个 colou?r color colour
数量 + ? 使前置数量进行惰性匹配 (尽可能少) b\w+? b be be e be er be ers
| 逻辑或,择一匹配 b(a|e|i)d bad bud bod bed bid

锚点

表达式 说明 示例 结果
^ 匹配字符串开头 ^\w+ she sells seashells
$ 匹配字符串结尾 \w+$ she sells seashells
\b 匹配 \w 和非 \w 的边缘位置 s\b she sell s seashell s
\B 匹配非边缘位置 s\B s he s ells s ea s hells

捕获组

表达式 说明 示例
(OneV)+ 捕获括号内的匹配,使其成组并出现在匹配结果中 OneV ’s Den is a blog.
(?<name>OneV)+ 命名捕获匹配,在结果中可使用名字对匹配结果进行引用
(?:OneV)+ 成组但不进行捕获,允许使用数量但不关心和捕获结果

Lookahead

表达式 说明 示例
\d(?=px) ?= - Positive lookahead。预先检查,符合时再进行主体匹配 1pt 2 px 3em 4 px
\d(?!px) ?! - Negative lookahead。预先检查,不符合时进行主体匹配 1 pt 2px 3 em 4px

Builder DSL

字面量表达式虽然简洁,但是对应复杂情境会难以理解,也不便于修改。使用 RegexBuilder 框架 提供的 DSL 来描述正则表达式是更具有表达性的方法。

比如,

1
let bitcoinAddress_v1 = /([13][a-km-zA-HJ-NP-Z0-9]{26,33})/

等效于:

1
import RegexBuilder
let bitcoinAddress_v1 = Regex {
  Capture {
    One(.anyOf("13"))
    Repeat(26...33) {
      CharacterClass(
        ("a"..."k"),
        ("m"..."z"),
        ("A"..."H"),
        ("J"..."N"),
        ("P"..."Z"),
        ("0"..."9")

Regex.init(_:) 接受一个 result builder 形式的闭包,你可以往闭包中塞入多个 RegexComponent 来构建完整的正则表达式。注意 Regex 类型本身也满足 RegexComponent 协议,所以你也可以直接把字面量传递给 Regex 初始化方法。

字面量所提供的特性,在 Regex Builder 中都有对应。除此之外,Swift Regex Builder 框架还提供了更易读的强类型描述。一些常见的对应 RegexComponent 如下:

字符集

字符集相关的 RegexComponent 基本被定义在 CharacterClass 中。

字面量表达式 等效的 RegexComponent
[aeiou] .anyOf("aeiou") 。为了可读性,可以考虑加上量词 One(.anyOf("aeiou"))
[^aeiou] CharacterClass.anyOf("aeiou").inverted
[A-Z] ("A"..."Z")
. .any
\s .whitespace
\S .whitespace.inverted
[\s\S] CharacterClass(.whitespace, .whitespace.inverted)
\w .word
\W .word.inverted
\d .digit
\D .digit.inverted

数量

字面量表达式 (例) 等效的 RegexComponent
+ ( b\w+ ) OneOrMore(.word)
* ( b\w* ) ZeroOrMore(.word)
{2,3} ( b\w{2,3} ) Repeat(2...3) { .word }
? ( colou?r ) Optionally { "u" }
数量 + ? ( b\w+? ) OneOrMore(.word, .reluctant)
| ( b(a|e|i)d ) ChoiceOf { "a" ↵ "e" ↵ "i" }

锚点

字面量表达式 (例) 等效的 RegexComponent
^ ( ^\w+ ) Regex { Anchor.startOfSubject ↵ OneOrMore(.word) }
$ ( \w+$ ) Regex { OneOrMore(.word) ↵ Anchor.endOfSubject }
\b ( s\b ) Regex { "s" ↵ Anchor.wordBoundary }
\B ( s\B ) Regex { "s" ↵ Anchor.wordBoundary.inverted }

此外:

  • 对于多行匹配模式的情况 (如带有 m /^abc/m ),此时 ^ $ 等效为 .startOfLine .endOfLine 等。
  • 对于 Unicode 支持,常用的还有 .textSegmentBoundary (\y) 等。

捕获

字面量表达式 等效的 RegexComponent
(OneV)+ OneOrMore { Capture { "OneV" } }
(?<name>OneV)+ let name = Reference(Substring.self)
OneOrMore { Capture(as: name) { "OneV" } }
(?:OneV)+ OneOrMore { "OneV" }

Regex Builder 支持在 Capture 的过程中同时进行 mapping,把结果转换为其他形式的字符串甚至是其他类型的强类型值:

1
Regex {
  TryCapture(as: kind) {
    OneOrMore(.word)
  } transform: {
    Transaction.Kind($0)
  } // 得到一个强类型 `Kind` 值

如果转换可能会失败并返回 nil ,使用 TryCapture :失败时跳过匹配;如果转换一定会成功,使用普通的 Capture

Lookahead

字面量表达式 等效的 RegexComponent
\d(?=px) Regex { .digit ↵ Lookahead { "px" } }
\d(?!px) Regex { .digit ↵ NegativeLookahead { "px" } }

常用 Parser

相对于字面量,使用 Regex Builder 的最大优势,在于可以嵌套使用已经存在的 Parser 进行匹配。凡是满足 RegexComponent 的值,都可以放到 Regex 表达式中。Foundation 中,部分 ParseStrategy 满足 RegexComponent 并提供相应方法来创建 Regex 中可用的 parser。iOS 16 中,默认可用 Parser 有:

所属 Parser 类型 方法签名 可解析示例
Date.ParseStrategy date(_:locale:timeZone:calendar:) Oct 21, 2015, 10/21/2015, etc
Date.ParseStrategy date(format:locale:timeZone:calendar:twoDigitStartDate:) 05_04_22
Date.ParseStrategy dateTime(date:time:locale:timeZone:calendar:) 10/17/2020, 9:54:29 PM
Date.ISO8601FormatStyle iso8601(timeZone:...) 2021-06-21T211015
Date.ISO8601FormatStyle iso8601Date(timeZone:dateSeparator:) 2015-11-14
Date.ISO8601FormatStyle iso8601WithTimeZone(...) 2021-06-21T21:10:15+0800
Decimal.FormatStyle.Currency localizedCurrency(code:locale:) $52,249.98 -> Decimal
Decimal.FormatStyle localizedDecimal(locale:) 1.234, 1E5 -> Decimal
FloatingPointFormatStyle<Double> localizedDouble(locale:) 1.234, 1E5 -> -> Double
FlatingPointFormatStyle<Double>.Percent localizedDoublePercentage(locale:) 15.4%, -200% -> Double
IntegerFormatStyle<Int> localizedInteger(locale:) 199, 1.234 -> Int
IntegerFormatStyle<Int>.Currency localizedIntegerCurrency(code:locale:) $52,249.98 -> Int
IntegerFormatStyle<Int>.Percent localizedIntegerPercentage(locale:) 15.4%, -200% -> Int

关于 Foundation 中 ParseStrategy 的相关内容,可以参看肘子兄的 这篇博客 ,以及 WWDC 21 中 相关的视频

自定义 Parser 和 CustomConsumingRegexComponent

对于自己实现的或是第三方提供的 Parser,可以通过满足 CustomConsumingRegexComponent 来让它进而满足 RegexComponent 并用在 Regex 构造中。

1
func consuming(
    _ input: String,
    startingAt index: String.Index,
    in bounds: Range<String.Index>
) throws -> (upperBound: String.Index, output: Self.RegexOutput)?

返回匹配停止时的上界,以及比配得到的结果本身即可。对于这一点,WWDC 22 的 Swift Regex: Beyond the basics 给了一个非常好的例子:

1
import Darwin
struct CDoubleParser: CustomConsumingRegexComponent {
    typealias RegexOutput = Double
    func consuming(
        _ input: String, startingAt index: String.Index, in bounds: Range<String.Index>
    ) throws -> (upperBound: String.Index, output: Double)? {
        input[index...].withCString { startAddress in
            var endAddress: UnsafeMutablePointer<CChar>!
            let output = strtod(startAddress, &endAddress)
            guard endAddress > startAddress else { return nil }
            let parsedLength = startAddress.distance(to: endAddress)
            let upperBound = input.utf8.index(index, offsetBy: parsedLength)
            return (upperBound, output)

在很多情况下,我们可能会进一步地使用 protocol 中泛型上下文静态查找 的特性,为 RegexComponent 添加类型成员,以便在 Regex 中直接使用:

1
extension RegexComponent where Self == CDoubleParser {
    static var cDouble: Self { CDoubleParser() }

Foundation 中的各种 parser 基本都遵循了类似的实现方式。

匹配方式

常见的匹配方法

1
// 匹配所有可能项,并将全部结果返回
input.matches(of: regex) // [Regex<Output>.Match]
// 匹配时返回第一个结果
input.firstMatch(of: regex) // Regex<Output>.Match?
// 整个字符串能完整匹配时才返回结果
input.wholeMatch(of: regex) // Regex<Output>.Match?
// 字符串的开始部分匹配的话返回结果
// 如果只需要判断是否匹配,使用 `start(with:)`
input.prefixMatch(of: regex) // Regex<Output>.Match?

匹配后得到的结果中, .0 返回匹配到的整个字符串,从 .1 开始是捕获的组:

1
let regex = /Welcome to (.+?), a person blog from (\d+)/
let text = "Welcome to OneV's Den, a person blog from 2011"
if let result = text.wholeMatch(of: regex) {
    print("Title: \(result.1)") // OneV's Den
    print("Year: \(result.2)")  // 2011

Regex.Match 实现了 dynamic lookup,可以使用 Reference 直接获取命名的捕获:

1
let regex = /Welcome to (?<name>.+?), a person blog from (?<year>\d+)/
let text = "Welcome to OneV's Den, a person blog from 2011"