WWDC22 | session 110357 | 邂逅Swift Regex

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

上一节回顾

通过session 110354短短的5min对于Swift Regex的简介,我们对于Swift Regex有了一个初步认识,本节通过对session 110357的剖析,让我们来邂逅Swift Regex

在Swift处理字符串的过程中,遇到的问题

视频的一开始,引入了一个鲜活的例子,分析一段交易数据。

我们以为是一段格式统一的数据,然而得到的却是一段字符串: image.png

基于字符串是遵守Collection协议的一种类型,我们想到了以下几种方式进行加工处理: image.png

  • 利用Collection协议中函数式编程的方法,进行处理,比如map,filter,splite

  • 利用字符串的索引进行切片处理

image.png

但是,不管是通过函数式编程的方法还是通过字符串的索引方法,它们都不尽人意,甚至写到后面,会找不到北。

根本原因是:这些方法,它们是针对字符串的每个字符元素做操作,而我们希望匹配的有效信息,它是一段又一段有一定格式规律的字符串。

用面向字符元素的方法去需找其中有用的字符串片段,虽然可以做到,但是显然不够高效。

这个时候,我们应该怎么办呢?

他山之石

其他编程语言都有通过正则表达式去匹配有效字符串的。

Apple的开发工程师当然可以通过尝试编写正则表达式来处理字符串,并且NSRegularExpression就是Foundation框架中正则表达式应用的类。

Swift亦是通过其他编程语言的正则表达式匹配为灵感,进而开发出了Swift Regex框架。

struct Regex<Output>的介绍与使用

我们可以看到Regex它是一个结构体类型,并且有一个泛型参数Output

Regex的3种方式创建

image.png

  • 我们可以通过//这种字面量的方式创建Regex,这种方式对应的Output类型为Substring(子字符串),这种方式对于熟悉正则表达式编写的开发者非常友好。

可能大家对这个字面量创建感到熟悉又陌生,给一个例子就明白了:

/// 字面量创建数组
let array = [1, 2, 3]

/// 构造器方法创建数组,本质调用的是init<S>(_ s: S) where Element == S.Element, S : Sequence这个方法
let array = Array(1...3)

所以字面量创建Regex,和字面量创建Array是同一个概念。

  • 我们可以通过构造器方式创建Regex,这种方式对应的Output类型为AnyRegexOutput,我认为AnyRegexOutput应该是一种擦除类型。

注意这种构造器方法的前面有try来修饰,因为只有在运行时我们才能知道这个正则对象是否正确,这个构造器方法与NSRegularExpression的构造器方法有异曲同工之妙,它们的构造器函数都带有throws即提示开发人员必须注意构造出的对象是否成功,并可能返回nil对象。

open class NSRegularExpression : NSObject, NSCopying, NSSecureCoding {

    public init(pattern: String, options: NSRegularExpression.Options = []) throws

}
  • 最后我们可以通过声明式的DSL语法,通过RegexBuild去构建一个创建Regex,这种方式对应的Output类型也为Substring,这种方式虽然看起来有点费代码,不过语义表达相当的好。

这个方式有点用空间换时间的味道,代码变多变长了,但是理解需要花的时间却变少了。

/// 匹配一个或者多个数字
let digits = OneOrMore(.digit)

小试牛刀

接下来,我们用字面量创建Regex,来尝试解析这段字符串吧:

let transaction = "DEBIT   03/05/2022   Doug's Dugout Dogs"

通过/\s{2,}|\t/这个正则进行separator操作。

\s{2,}表示匹配2个以及其以上任何不可见字符,\t匹配一个制表符,而|就是的意思。

让我们试试新的API:

let fragments = transaction.split(separator: /\s{2,}|\t/)

可以获取这些信息:

["DEBIT", "03/05/2022", "Doug's Dugout Dogs", "$33.27"]

接着我们在separator操作之后,然后再进行join操作:

let normalized = transaction.split(separator: /\s{2,}|\/).joined (separator: "It")

这些信息变成了如下所示:

DEBIT»03/05/2022»Doug's Dugout Dogs»$33.27

通过replacing方法,我们可以更简洁的获取到同样的结果:

let normalized = transaction.replacing(/\s{2,}|\t/, with: "\t")
// DEBIT»03/05/2022»Doug's Dugout Dogs»$33.27

现在我们可以看到,通过创建Regex,我们可以更有效的去匹配想要的数据了,但是问题也随之而来。

如何new一个正则表达式,是个问题

有些人在碰到问题时,就想:“我知道,我可以使用正则表达式。”现在,他们就有了两个问题。

-Jamie "ajwz" Zawinski, 1997年8月

引用自《Python核心编程》第3版,第1章:正则表达式,章节首页语

本session同样引用了这段话。

可见,编写一个符合业务场景的正则表达式有多么的困难,/\s{2,}|\t/只是一个比较简单的正则,但是对于一个不懂正则表达式的开发者而言,简直就是天书。

为了解决问题而生产了新的问题,这是莫比斯环,会深深的陷进去。

而Swift Regex的出现,恰恰就是要解决这些的。

Swift Regex的优势

image.png

  • 通过RegexBuild构建的正则,言简意赅,表现力强
  • 正则解析器融入到正则表达式中,可独立使用并复用
  • 支持Unicode编码级别的正则匹配(其实我对Swift String理解不不够深刻,但就我对于其他编写语言的了解,对于Unicode编码的支持,Swift很有优势,并不是所有的编程语言对Unicode编码都有很好的支持)
  • 可预测执行,并可控

Talk is cheap, show me the code

牛逼吹了这么多,不如实战一把。

Swift Regex再战

image.png

import RegexBuilder

let fieldSeparator = /\s{2,}|\t/

let transactionMatcher = Regex {
    /CREDIT| DEBIT/
    fieldSeparator
    One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .qmt))
    fieldSeparator
    OneOrMore {
        NegativeLookahead { fieldSeparator }
        CharacterClass.any
    }
    fieldSeparator
    One (.localizedCurrency(code: "USD" ).locale (Locale(identifier: "en US")))
}

我们先引入了RegexBuilder框架,再将可以复用的/\s{2,}|\t/的抽离出来,最后开始编写Regex

如果熟悉SwiftUI的同学,马上就感受到了熟悉的味道,这种DSL与SwiftUI师出同门。

就算不熟悉SwiftUI,写Flutter在构建UI的时候也会有类似的代码,这种声明式的语法结构随处可见,这里不扩展了,有兴趣的同学可以自行查阅资料了解。

Regex的编写用过横向来编写每个匹配单元,通过纵向来区分不同的匹配单元。

在这段代码中: /CREDIT| DEBIT/去匹配CREDIT或者DEBIT;

fieldSeparator去匹配空格或者制表符;

One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .qmt))去匹配日期;

OneOrMore { NegativeLookahead { fieldSeparator } CharacterClass.any }去匹配一段任何字符,即内容;

One (.localizedCurrency(code: "USD" ).locale (Locale(identifier: "en US"))) 去匹配金额;

因为目前工作原因,我无法升级mac到beta版本,Xcode也是不敢升级,所以Swift Regex的API用法,无法展开讨论,见谅。

这里的NegativeLookahead { fieldSeparator }可以进行一下说明:

image.png

最初一开始视频中去匹配内容写的是OneOrMore { CharacterClass.any },但是这样有一个问题,这个正则去匹配任意字符时,如果不进行限制的话,会一直匹配到Doug's Dugout Dogs以及后面的空格,这当然和我们业务需求的不一致,所以添加了NegativeLookahead { fieldSeparator }这段代码。

这里涉及到正则匹配一个模式:贪婪非贪婪,也就是尽可能多的去匹配字符串或者是尽可能少的去匹配,而NegativeLookahead { fieldSeparator }的含义,就是当发现fieldSeparator的时候,就应该终止后续的匹配了,这样就能匹配到业务需求的内容。

有关Swift Regex贪婪与非贪婪,会在session 110358中进行更详细的说明,这里记住一个匹配规则,那就是默认的正则匹配都是贪婪的。

就这样,通过Swift Regex解析字符串的工作总算告一段落。

使用Capture去提取正则信息

当然,我们不仅需要去匹配到相应的字符串,同时还需要将匹配的细节内容提取出来,这时在匹配单元外面包裹Capture就可以了,还是熟悉的语法。

image.png

这个时候,struct Regex<Output>中的Output返回是一个包含多个值的元组

一一对应这个Output元组表达的内容就显得非常的重要,同时也是我们需要优化的一个地方,下文会开展说

Output.0是正则匹配的整体结果字符串,而之后元组的每一个值就依次对应每一个Capture

对于Capture匹配的值,Swift做了一步到位的处理,比如Output.2匹配的是时间,输出的类型就是Date,Output.4匹配的是货币,输出的类型就是Decimal。

Output元组结果的优化

如果对Python的正则匹配熟悉的话,你会发现PythonNSRegularExpression在返回结果的一些相似之处:

import re 

line = "Cats are smarter than dogs"; 
searchObj = re.search( r'(.*) are (.*?) .*', line, re.M|re.I) 

if searchObj: 
    print "searchObj.group() : ", searchObj.group() 
    print "searchObj.group(1) : ", searchObj.group(1) 
    print "searchObj.group(2) : ", searchObj.group(2) 
else: 
    print "Nothing found!!"
  

输出的结果如下:

# 整体匹配,相当于Swift Regex中的Output.0
searchObj.group() :  Cats are smarter than dogs

# 第一个匹配,相当于Swift Regex中的Output.1
searchObj.group(1) :  Cats

# 第二个匹配,相当于Swift Regex中的Output.2
searchObj.group(2) :  smarter

Python中对于结果searchObj通过.group()去获取整个匹配的字符串,通过group(1)group(2)区分别提取相应的内容。

Python的正则匹配的返回结果可以认为是一种类数组结构。

NSRegularExpression中的匹配方法,亦返回是数组类型:

open func matches(in string: String, options: NSRegularExpression.MatchingOptions = [], range: NSRange) -> [NSTextCheckingResult]

我们会发现这样的一个应用问题,返回的数据类型是数组,那么就只能通过下标去获取对应的值,这样既不好理解,又需要小心数组越界。

而Swift Regex则是通过元组去整合Output,又有什么玄机呢?我们先来看一个简单的例子:

func getInfo() -> (Int, Double) {

    return (28, 170.0)

}

let a = getInfo()
print(a.0)
print(a.1)

上面这个例子,我们完全不知道(Int, Double)这个元组返回值的意义是什么。

我们再来看一下优化后的:

func getInfo() -> (age: Int, height: Double) {

    return (28, 170.0)

}

let a = getInfo()
print(a.age)
print(a.height)

通过对元组添加修饰的属性名称,我们可以更好的理解返回数据的意义!

Swift Regex可以通过代码修饰,让Regex<Output>返回值时,增加对Output元组属性名称的支持!!!

image.png

我们可以看看视频中对于Output的元组优化:

image.png

在编写Regex的过程中,添加?<date>?<date>?<currency>这些自然语言修饰,加上在##修饰之间字符串的特性,Output的元组也拥有了属性名称——Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>

image.png

这样一来,pickStrategy(_ currency: Substring)方法因为有明确的入参(Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>中的currency),调用起来也舒心多了。

我们通过currency区分美元与英镑符号,进而返回对应的日期格式。

最后,针对匹配的日期格式,进行标准化的iso8601格式输出。

image.png

这样一来,视频中提出的:付款是美元还是英镑而导致日期书写方式的不同,如何进行正确匹配的问题迎刃而解。

最终,我们将匹配的日期信息做了统一处理,这些格式化后的数据写入数据库,对于维护庞大数据的系统自然更加友好。

优化前 优化后
image.png image.png

因为中文视频播到14min左右的时候就没有了,所以从下面的内容开始,我都是自己盲听与理解的。虽然,自己大概理解主讲人在说明什么,但是通过自己的理解写成文字,或多或少会有问题甚至错误,欢迎大家指正。


匹配的功能扩展

随着业务需求的变化,交易数据又又又有了新的变化!

image.png

如图所示,数据中有出现了两个字段TIMESTAMP和DETAILS,而且这两个字段可能会包含有效的字符串,抑或什么都没有,又抑或仅仅包含占位字符串,我们该怎么办呢?

  • 有还是没有,无效字符串占位,这些都是问题。

  • 怎么写正则去满足业务要求也是问题。

我们根据之前积累经验,先做正则的模块化:

/// 独立编写时间戳正则
let timestamp = Regex{} // proprietary

/// 独立编写detail的正则
let details = try Regex(inputString)

/// 独立编写出amount的正则
let amountMatcher = /[ld.]+/

/// 之前编写的fieldSeparator正则
let fieldSeparator = /\s{2,}|\t/

然后我们开始进行整个Regex的编写:

// CREDIT  «proprietary>  <redacted>  200.23  A1B34EFF

let transactionMatcher = Regex {
    Capture { /CREDIT|DEBIT/ }
    fieldSeparator
    
    Capture { timestamp }
    fieldSeparator
    
    Capture { details }
    fieldSeparator
    
    //..
}

上面这种写法对于TIMESTAMP和DETAILS有可以匹配的值时候是可行的,但是显然对于例子中这种占位符,就会出现异常。

TryCapture

我们对«proprietary>其所在占位区域进行处理,还是记得上面我们使用过的OneOrMore { NegativeLookahead { fieldSeparator } CharacterClass.any }吗,我们可以先尝试匹配到任意的内容,然后再通过timestamp去匹配时间戳。

于是,我们跟着这个思路继续写:

/// 独立编写出非贪婪捕获任意内容的正则
let field = OneOrMore {
    NegativeLookahead { fieldSeparator }
    CharacterClass.any
}

关键的一步到了,我们匹配到任意内容后,如何继续去匹配时间戳正则呢?

TryCapture出场了:

let transactionMatcher = Regex {
    Capture { /CREDIT/ DEBIT/ }
    fieldSeparator
    
    TryCapture (field) { timestamp ~= $0 ? $0 : nil }
    fieldSeparator
    
    TryCapture (field) { details ~=$0? S0 : nil }
    fieldSeparator
    
    //..
}

我们来详细说说这一段代码TryCapture (field) { timestamp ~= $0 ? $0 : nil }

TryCapture (field)表示尝试去匹配field正则,这和我们在Swift开发中使用try?语法类似,相当于尝试去匹配,也就是TryCapture匹配的结果是一个可选类型。

接着我们看看{ timestamp ~= $0 ? $0 : nil },针对TryCapture (field)尝试匹配的任意内容,我们进行再次匹配,timestamp ~= $0就是表达的这个意思,注意这里的~=是一个运算符。

~=运算符出自Compareable协议,表示的是判断某个值在某个范围类,我们无法通过例子中的代码看出~=是否被重载了

但是这段代码的逻辑应该是,TryCapture (field)尝试匹配的任意内容是否和timestamp匹配得上,如果匹配上了,就返回这段内容,如果匹配不上,就返回nil。

同理,TryCapture (field) { details ~=$0? S0 : nil }的含义我们可以一举反三,就不重复拆解分析了。

image.png

到此,这个新需求,在TryCapture的努力下解决了。

但是,同时也遗留下匹配效率的问题。

我们接着往下说。

全局匹配与局部匹配

image.png

本例子中,对于let fieldSeparator = /\s{2,}|\t/这个正则匹配,它会进行全局的匹配搜索,这种global模式是默认的,于是乎,我们可以看见视频中,光标不停移动与返回的操作。

在例子中,这么做没有什么大问题,因为通过全局匹配,索引的转变,我们最终可以得到正确的匹配结果,但是想想:

let transactionMatcher = Regex {
    Capture { /CREDIT/ DEBIT/ }
    fieldSeparator
    
    TryCapture (field) { timestamp ~= $0 ? $0 : nil }
    fieldSeparator
    
    TryCapture (field) { details ~=$0? S0 : nil }
    fieldSeparator
    
    //..
}

这段代码有3个fieldSeparator,使用全局匹配,会影响性能与效率,我们有更优解——局部匹配

通过局部匹配,我们在匹配过程中,会缩小匹配的范围,更快的进入下一个匹配规则。

最终的代码,我们在/\s{2,}|\t/外面包裹了Local字段,以表示该段Regex为局部匹配。

let fieldSeparator = Local { /\s{2,}|\t/ }

image.png

至于,如何理解全局匹配和局部匹配,以及使用场景,我目前还在通过其他编程语言进行实践。

Swift String与Unicode

说来很惭愧,我对于本session中12分40秒到16分30秒的视频内容理解不太透彻,这期间视频穿插讲解了Swift是以以Unicode编码为基础的匹配。

正是因为Swift的字符串构建是基于Character值的集合,并且是Unicode编码,所以Swift Regex的匹配才能如此准确。

如果大家有条件的话,可以阅读一下《Swift进阶》字符串这个章节的内容,下面这些段落摘自原作者对于Swift String的一些理解与评价:

Swift在字符串实现上做出了英勇的努力,它力求尽可能做到Unicode正确。Swift中的String是Character值的集合,而Character是人类在阅读文字时所理解的单个字符,这与该字符由多少个Unicode码点组成无关。

Swift的字符串和其他所有主流编程语言中的字符串都很不同。如果你已经习惯了将字符串作为编码单元数组进行处理的话,你可能会需要一点时间来切换你的思维方式,相比于简洁,Swift中的字符串以Unicode正确性为第一优先。

我们认为Swift做出了正确的选择。其他编程语言假装Unicode文本没有那么复杂,其实这不是真相。在⻓远看来,严格的Swift字符串可以让你避免写出一些本来会出现的bug,这可以节省下很多时间。与之相比,努力去忘却整数索引所花费的时间完全不足为意。

session 110357小结

image.png

  • 我们可以通过3种方式创建Regex,它们分别是:字面量、构造器与语法糖。每个人对正则表达式的理解程度不同,选择合适自己的方式进行创建

  • 通过RegexBuild与语法糖我们快速编写Regex,通过,编写匹配单元与分组

  • 使用CaptureAPI,我们可以将匹配的内容输出到RegexOutput中,此时Output是一个元组结果集,通过优化,我们可以让Output元组结果集的元素拥有属性名称,让元组的每一个元素都具有含义

  • 使用TryCaptureAPI,通过Try,我们可以更加灵活的面对复杂的需求场景,获得有效信息

  • 用全局匹配的模式去思考,同时匹配的时候谨慎的使用局部匹配,性能与效率是我们需要关心的

  • Unicode模式是Swift的String默认模式

下节预告

计划是将session 110357和110358合并在一块进行写作的,如大家所见,又又又被拆开了。

我反反复复观看了session 110357的视频,希望融会贯通,可惜自己太菜,还是无法解读所有的内容,也欢迎各位大佬指点一二。

对Python爬虫有点理解,算是对于Swift Regex的理解与写作帮上了忙。

下一节,会对session 110358进行解读——Swift Regex进阶。

谢谢大家。

参考资料


猜你喜欢

转载自juejin.im/post/7111705100527075335
今日推荐