WWDC Quick Look 💓 By SwiftGGTeam
Swift Regex: Beyond the basics

Swift Regex: Beyond the basics

观看原视频

Highlight

Swift 5.7 为字符串处理引入了 Regex 类型、正则字面量语法和 RegexBuilder DSL,开发者可以用类型安全的方式匹配、提取和转换字符串内容,还能将 Foundation 的日期/数字解析器直接嵌入正则表达式中。

核心内容

处理字符串是编程中最常见的任务之一。从日志解析到数据校验,开发者每天都在写各种字符串匹配代码。

在 Swift 5.7 之前,处理正则表达式主要依赖 NSRegularExpression。这个 API 来自 Objective-C 时代,用起来繁琐:匹配结果是 NSTextCheckingResult,捕获组要通过数字索引访问,类型全是 StringRange,很容易出错。

Swift 5.7 带来了全新的正则解决方案。核心是三样东西:

  1. Regex 类型:标准库新增的正则类型,强类型、可组合
  2. 正则字面量:编译器检查语法,Xcode 支持语法高亮
  3. RegexBuilder DSL:用 Swift 代码描述正则模式,可读性接近自然语言

这三者的组合让字符串处理从”黑魔法”变成了普通的 Swift 代码。

从字符串到类型安全的匹配

假设你要从一段文本中提取用户 ID:

name:  John Appleseed,  user_id:  100

以前用 NSRegularExpression 需要写一堆样板代码。现在用 Swift Regex,有三种写法可选。

从字符串创建(运行时解析):

let regex = try Regex(#"user_id:\s*(\d+)"#)

正则字面量(编译期检查):

let regex = /user_id:\s*(\d+)/

RegexBuilder DSL(最佳可读性):

import RegexBuilder

let regex = Regex {
    "user_id:"
    OneOrMore(.whitespace)
    Capture(.localizedInteger)
}

三种写法创建的 regex 都是 Regex 类型,可以传给统一的匹配算法。

匹配算法一览

Swift 标准库为 StringSubstring 扩展了一批正则驱动的算法(03:49):

let input = "name:  John Appleseed,  user_id:  100"
let regex = /user_id:\s*(\d+)/

input.firstMatch(of: regex)           // 找第一个匹配
input.wholeMatch(of: regex)           // 整个字符串必须匹配
input.prefixMatch(of: regex)          // 前缀匹配
input.starts(with: regex)             // 是否以模式开头
input.replacing(regex, with: "456")   // 替换匹配内容
input.trimmingPrefix(regex)           // 去掉前缀匹配部分
input.split(separator: /\s*,\s*/)     // 按正则分割

正则还能用在 switch 语句中:

switch "abc" {
case /\w+/:
    print("It's a word!")
}

解析银行对账单

Foundation 的解析器可以直接嵌入 RegexBuilder05:14):

let statement = """
    DSLIP    04/06/20 Paypal  $3,020.85
    CREDIT   04/03/20 Payroll $69.73
    """

let regex = Regex {
    Capture(.date(format: "\(month: .twoDigits)/\(day: .twoDigits)/\(year: .twoDigits)"))
    OneOrMore(.whitespace)
    OneOrMore(.word)
    OneOrMore(.whitespace)
    Capture(.currency(code: "USD").sign(strategy: .accounting))
}

这里 .date().currency() 是 Foundation 提供的解析器,支持本地化,处理了各种边界情况。你不需要自己写日期解析逻辑。

详细内容

用 Capture 提取数据

匹配只是第一步,提取数据才是关键。Capture 可以把匹配到的子串保存下来,追加到输出元组中(10:28):

let regex = Regex {
   "a"
   Capture("b")
   "c"
   /d(e)f/
}

if let match = "abcdef".wholeMatch(of: regex) {
    let (wholeMatch, b, e) = match.output
    // wholeMatch = "abcdef"
    // b = "b"
    // e = "e"
}

关键点:

  • match.output 是一个元组,第一个元素是整个匹配,后面依次是各个 Capture
  • 正则字面量中的括号 () 也是捕获组
  • 输出元组的类型在编译期就确定了

实战:解析 XCTest 日志

假设你要解析测试套件日志:

Test Suite 'RegexDSLTests' started at 2022-06-06 09:41:00.001
Test Suite 'RegexDSLTests' failed at 2022-06-06 09:41:00.001.
Test Suite 'RegexDSLTests' passed at 2022-06-06 09:41:00.001.

第一步,写出基础正则(06:24):

import RegexBuilder

let regex = Regex {
    "Test Suite '"
    /[a-zA-Z][a-zA-Z0-9]*/
    "' "
    ChoiceOf {
        "started"
        "passed"
        "failed"
    }
    " at "
    OneOrMore(.any)
    Optionally(".")
}

关键点:

  • ChoiceOf 匹配多个选项中的一个
  • Optionally 匹配零次或一次
  • 正则字面量 /[a-zA-Z][a-zA-Z0-9]*/ 可以嵌在 DSL 中

加上 Capture 提取数据(11:10):

let regex = Regex {
    "Test Suite '"
    Capture(/[a-zA-Z][a-zA-Z0-9]*/)
    "' "
    Capture {
        ChoiceOf {
            "started"
            "passed"
            "failed"
        }
    }
    " at "
    Capture(OneOrMore(.any))
    Optionally(".")
}

for line in testInputs {
    if let (whole, name, status, dateTime) = line.wholeMatch(of: regex)?.output {
        print("\(name), \(status), \(dateTime)")
    }
}

处理贪婪匹配问题

运行上面的代码,你会发现 dateTime 包含了末尾的句点。原因是 OneOrMore(.any) 默认是贪婪(eager)的,会尽可能多地匹配字符(11:51)。

修复方法是改成 reluctant(惰性)模式:

Capture(OneOrMore(.any, .reluctant))

关键点:

  • .reluctant 让重复匹配尽可能少的字符
  • 引擎会先尝试匹配后面的模式,失败才回溯消费更多字符
  • 也可以用 .repetitionBehavior(.reluctant) 修改整个正则的默认行为

Transforming Capture:从子串到具体类型

捕获到的内容默认是 Substring,通常需要转换成更有意义的类型。Capture 支持 transform 闭包(15:20):

Regex {
    Capture {
        OneOrMore(.digit)
    } transform: {
        Int($0)  // 输出类型变成 Int?
    }
}

如果转换可能失败,用 TryCapture,它会把可选类型”拍平”(15:55):

Regex {
    TryCapture {
        OneOrMore(.digit)
    } transform: {
        Int($0)  // 输出类型是 Int,不是 Int?
    }
}

关键点:

  • TryCapture 的 transform 返回 Optional,但输出类型是非可选的
  • 转换失败时引擎会自动回溯,尝试其他匹配路径
  • 适合配合可失败初始化器使用

用 Foundation 解析器处理日期

把状态转成枚举,把时间戳转成 Date16:21):

enum TestStatus: String {
    case started, passed, failed
}

let regex = Regex {
    "Test Suite '"
    Capture(/[a-zA-Z][a-zA-Z0-9]*/)
    "' "
    TryCapture {
        ChoiceOf { "started"; "passed"; "failed" }
    } transform: {
        TestStatus(rawValue: String($0))
    }
    " at "
    Capture(.iso8601(
        timeZone: .current,
        includingFractionalSeconds: true,
        dateTimeSeparator: .space))
    Optionally(".")
}
// 输出类型: Regex<(Substring, Substring, TestStatus, Date)>

关键点:

  • .iso8601() 是 Foundation 提供的日期解析器
  • 输出元组的最后一个元素直接是 Date 类型
  • 不需要手动做二次解析

自定义解析器:接入 C 库

如果你想接入已有的 C 解析器,可以实现 CustomConsumingRegexComponent 协议(19:16):

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)
        }
    }
}

使用自定义解析器(20:13):

let regex = Regex {
    "Test Case "
    OneOrMore(.any, .reluctant)
    "("
    Capture {
        CDoubleParser()
    }
    " seconds)."
}

if let match = input.wholeMatch(of: regex) {
    print("Time: \(match.1)")  // Double 类型
}

关键点:

  • consuming 方法返回匹配的上界索引和解析结果
  • 返回 nil 表示匹配失败,引擎会回溯
  • 这是接入现有解析库(如 JSON、YAML 解析器)的桥梁

核心启发

1. 日志解析器

做什么:为你的项目写一个 Xcode 构建日志解析器,提取编译错误和警告。

为什么值得做:用 RegexBuilder 描述日志格式,用 TryCapture 把错误信息转成结构化数据,比正则字符串更易维护。

怎么开始:定义一个 BuildLogEntry 结构体,用 RegexBuilder 匹配 error: / warning: 前缀,用 Capture 提取文件名、行号和错误描述。

2. 智能剪贴板工具

做什么:做一个 macOS 工具,自动识别剪贴板内容并提取关键信息(如快递单号、手机号、URL)。

为什么值得做:Swift Regex 的 firstMatchwholeMatch 可以直接用在剪贴板字符串上,配合 TryCapture 把匹配结果转成具体类型。

怎么开始:用 NSPasteboard 监听剪贴板变化,对文本内容尝试多种正则模式,匹配成功后显示提取结果。

3. 配置文件验证器

做什么:为团队内部的配置文件格式写一个验证工具,检查语法错误并给出友好提示。

为什么值得做:RegexBuilder 的可读性让配置规则一目了然,新成员能快速理解验证逻辑。

怎么开始:把配置文件的每一行定义成一个 Regex,用 wholeMatch 验证,失败时返回具体的错误位置和期望格式。

4. 数据迁移脚本

做什么:写脚本把旧系统的文本日志迁移到新数据库,用正则提取字段并转换类型。

为什么值得做:TryCapture 配合 Date / Int / Double 初始化器,一行正则就能完成提取+转换,不需要二次解析。

怎么开始:用 Foundation 的 .date().currency() 解析器处理日期和金额字段,自定义 CustomConsumingRegexComponent 处理特殊格式。

关联 Session

评论

GitHub Issues · utterances