NSHipster: NSRegular​Expression 中文版

NSHipster

原文: NSRegular​Expression

原作者:Nate Cook


遇到问题,哦,要用NSRegular​Expression了。 

其实呢,有一些,是要注意的。

正则表达式是一种DSL, 有一些讨论。说他不好,毕竟Regex里面都是各种符号。说他好,Regex简明强大,用途广泛。

公认的是,Cocoa 给NSRegular​Expression 设计了一套冗长的API. 先比对下Ruby,这段Ruby代码的作用是,从HTML代码片段中提取URL.

htmlSource = "Questions? Corrections? <a href=\"https://twitter.com/NSHipster\"> @NSHipster</a> or <a href=\"https://github.com/NSHipster/articles\">on GitHub</a>."

linkRegex = /<a\s+[^>]*href="([^"]*)"[^>]*>/i

links = htmlSource.scan(linkRegex)

puts(links)

# https://twitter.com/NSHipste

# https://github.com/NSHipster/articles

Ruby 三行代码实现。

现在看Swift 中用 NSRegularExpression ,同样的功能实现(从HTML代码片段中提取URL.)

let htmlSource = "Questions? Corrections? <a href=\"https://twitter.com/NSHipster\"> @NSHipster</a> or

<a href=\"https://github.com/NSHipster/articles\">on GitHub</a>."

let linkRegexPattern = "<a\\s+[^>]*href=\"([^\"]*)\"[^>]*>"   // 比起Ruby 的, 多了一个转义字符 '\'

let linkRegex = try! NSRegularExpression(pattern: linkRegexPattern, options: .caseInsensitive )

let matches = linkRegex.matches(in: htmlSource,  range: NSRange(location: 0, length: htmlSource.utf16.count))

let links = matches.map{ result -> String in

       let hrefRange = result.rangeAt(1)

       let start = String.UTF16Index(encodedOffset: hrefRange.location)         

       let end = String.UTF16Index(encodedOffset: hrefRange.location + hrefRange.length)

      return String(htmlSource.utf16[start..<end])!

}

print(links)

// ["https://twitter.com/NSHipster", "https://github.com/NSHipster/articles"]

{

效果图: 

简单说明: 

第一段,   <a\\s+ , 先找  <a 两个特定字符, 再来一个转义,寻找一到多个空格。

第二段, [^>]*, 要求 紧接着的任意的字符串中,不能包含 > .

第三段, href=\" . 寻找紧接着 href=\" 

第四段, ([^\"]*),紧接着的任意字符串不得包含 \"

第五段, \"[^>]*> , 先来一个 转义,再要求紧接着的字符串满足 ,* 和 > 之间, 不包含 > .

}


NSRegular​Expression 不好的,就说到这里。

原文(英文原版)不会深入浅出地讲解正则表达式(要自己学习 通配符‘*’ ‘+’  , 反向引用‘^’ ,提前量‘[]’  ,等等 )

Swift 中的 Regex 学习,  NSRegularExpression, NSTextCheckingResult , 注意下难点、特例, 就可以了。

字符串方法 , NSString Methods

上手Cocoa中的正则,当然是不用 NSRegular​Expression .

NSString 中的 range(of:...) 方法 可实现轻量级的字符串查找,需要用 .regularExpression 切换 regular expression mode .  ( OC 的 NSString, 对应 Swift 中的 String) 

OC 代码

NSString * source = @"For NSSet and NSDictionary, the breaking...";

// 作用是 , 匹配字符串中长得像Cocoa 类型 type 的 单词

// 例如: UIButton, NSCharacterSet, NSURLSession

NSString * typePattern = @"[A-Z]{3,}[A-Za-z0-9]+";

NSRange typeRange = [source rangeOfString: typePattern options: NSRegularExpressionSearch];

if( typeRange.location! = NSNotFound ){

       NSLog(@"First type: %@",[sourcesubstringWithRange:typeRange]);

       // First type: NSSet

}

Swift 代码

let source="For NSSet and NSDictionary, the breaking..."

// Matches anything that looks like a Cocoa type: 

// UIButton, NSCharacterSet, NSURLSession, etc.

let typePattern = "[A-Z]{3,}[A-Za-z0-9]+"

if let typeRange = source.range(of: typePattern , options: .regularExpression){

        print("First type: \(source[typeRange])")

        // First type: NSSet

}

{


link: https://regex101.com/r/U7TC8v/1

第一段, [A-Z]{3,} ,  用于匹配至少3个A-Z 中的字符。

第二段, [A-Za-z0-9]+ ,   用于匹配至少一个该集合中的字符,A-Z 之间 加上 a-z 之间, 再加上 0-9 之间

}

替换也是常用的功能,同样的选项option, 使用 replacingOccurrences(of:with:...) .

下面,用一个看起来怪的代码,在上句中的coco 类型单词外面加括号。看起来清楚一些吧。

OC 代码

NSString * markedUpSource = [source stringByReplacingOccurrencesOfString:typePattern withString: @"`$0`" options: NSRegularExpressionSearch range : NSMakeRange(0,source.length)];

NSLog(@"%@",markedUpSource);

// "For `NSSet` and `NSDictionary`, the breaking..."

Swift 代码

let markedUpSource = source.replacingOccurrences(of: typePattern, with: "`$0`", options: .regularExpression)

print(markedUpSource)

// "For `NSSet` and `NSDictionary`, the breaking...""

{

说明: 

这里有一个 正则表达式中,获取正则分段的概念。

可以参见 这个链接:     https://stackoverflow.com/questions/432493/how-do-you-access-the-matched-groups-in-a-javascript-regular-expression

}

用上面的替换模版,正则可以处理推导分组。西方有一个关于元音的字母转换,

OC 代码

NSString * ourcesay = [source stringByReplacingOccurrencesOfString: @"([bcdfghjklmnpqrstvwxyz]*)([a-z]+)" withString: @"$2$1ay" options: NSRegularExpressionSearch | NSCaseInsensitiveSearch range: NSMakeRange(0,source.length)];

NSLog(@"%@",ourcesay);

// "orFay etNSSay anday ictionaryNSDay, ethay eakingbray..."

Swift 代码

let ourcesay = source.replacingOccurrences(of: "([bcdfghjklmnpqrstvwxyz]*)([a-z]+)", with: "$2$1ay", options: [.regularExpression,.caseInsensitive])

print(ourcesay)

// "orFay etNSSay anday ictionaryNSDay, ethay eakingbray..."

{


link :   https://regex101.com/r/lZxWuY/2

第一段, ([bcdfghjklmnpqrstvwxyz]*)  , 匹配不限长度的 不含 a e i o u 的 任意英文字母。

第二段, ([a-z]+) ,  匹配 至少一个长度的 任意英文字母

}

很多需要运用正则的场景下,上面两个方法就可以了。复杂的功能实现,就要用到NSRegularExpression这个类了。首先, 解决Swift中的一个正则新手易犯错误。

NSRangeand Swift

比起 Foundation 的 NSString , Swift有着作用域更大、更复杂的API ,来处理字符串的字符和子串。Swift的标准库有四种接口来处理字符数据,可以用字符、Unicode 标量、UTF-8 码、 UTF-16 码 来获取字符串的数据。

这与 NSRegularExpression 相关,很多 NSRegularExpression 方法使用 NSRange, 用 NSTextCheckingResult 对象保存匹配到的数据。 NSRange 使用整型 integer ,记录他的起始点 location 和 字符长度 length 。但是字符串 String 是不用整型 integer 作为索引的

let range = NSRange(location: 4, length: 5)

// 下面的代码,是编不过的

source[range]

source.characters[range]

source.substring(with:range)

source.substring(with:range.toRange()!)

接着来。

Swift 中的 String 其实是通过 utf16 接口操作的,同 Foundation 框架下 NSString 的 API 一样。可以通过 utf16 接口的方法,用整型 integer 创建索引。 

let start = String.UTF16Index(encodedOffset: range.location)

let end = String.UTF16Index(encodedOffset: range.location + range.length)

let substring = String(source.utf16[start..<end])!

// substring 现在是 "NSSet"

下面放一些 String 的 Util 代码,调用 Swift 相关正则的语法糖, 有 Objective-C 的感觉

extensionString{

        /// 这个 nsrange 属性 ,包含了字符串的所有范围

        var nsrange: NSRange{

                return NSRange(location:0,length:utf16.count)

        }

        /// 用之前给出的 nsrange 属性,返回一个字串。 

       /// 如果字符串中没有这个范围, 就 nil 了

        func substring( with nsrange: NSRange) -> String?{

                guard let range = Range(nsrange, in: self)

                    else { return nil }

                 return String( self[range] )

        }

        /// 返回 与之前掏出来的 nsrange 属性,等同的 range

        /// 如果字符串中没有这个范围, 就 nil 了

        func range(from nsrange: NSRange) -> Range<Index>?{

                guard let range = Range(nsrange, in: self)           

                        else { return nil }       

              return range

            }

      }

接下来体验的 NSRegularExpression ,有用到上面的 Util 方法。

NSRegularExpression 和 NSTextCheckingResult

之前学习了在字符串中找出第一个匹配到的数据,与匹配到的数据之间的替换。复杂些的情况,就要用到 NSRegularExpression 了。先造一个简单的文本各式匹配 miniPattern ,找出文本中的 *bold* 和 _italic_

造一个 NSRegularExpression 对象,要传入一个匹配规则的字符串 pattern ,还有一些选项可以设置。miniPattern 用星号 * 或 下划线 _ 开始查找匹配的单词。找到星号或下划线后,就匹配一个到多个字符的格式,用找到的第一个匹配的字符再次match终止一次查找。匹配到的首字母和文本,都会被保存到查询结果中。

OC 代码

NSString * miniPattern = @"([*_])(.+?)\\1";

NSError * error = nil;

NSRegularExpression * miniFormatter = [ NSRegularExpression regularExpressionWithPattern: miniPattern options: NSRegularExpressionDotMatchesLineSeparators error: &error];

Swift 代码

let miniPattern = "([*_])(.+?)\\1"

let miniFormatter = try! NSRegularExpression(pattern: miniPattern, options: .dotMatchesLineSeparators)

// 如果 miniPattern 有误, NSRegularExpression 初始化就会抛异常。

如果 pattern有误, NSRegularExpression 初始化就会抛异常。一旦 NSRegularExpression 对象建好了,就可以用它处理不同的字符串。

{

说明: 

"([*_])(.+?)\\1"  , 这个正则表达式  分三段,

第一段 ([*_]) ,匹配 中括号 中的 任意一个字符, 就是 * 或者 _ ;

第二段 (.+?) , 匹配 长度大于1的 任意字符串;

第三段 \\1, 有一个转义字符, 匹配之前获取到的第一个同等字符串

}

OC 代码

NSString * text = @"MiniFormatter handles *bold* and _italic_ text.";

NSArray * matches = [miniFormatter matchesInString: text options: kNilOptions range:  NSMakeRange(0,text.length)];

// matches.count == 2

Swift 代码

let text = "MiniFormatter handles *bold* and _italic_ text."

let matches = miniFormatter.matches(in: text, options: [], range: text.nsrange )

// matches.count == 2

调用 matches(in:options:range:) 方法,可以取出包含 NSTextCheckingResult 元素的数组。 多种文本处理类都有用到NSTextCheckingResult 类型,譬如 NSDataDetector 和 NSSpellChecker . 返回的数组中,一个匹配有一个NSTextCheckingResult .

通常要取得的是匹配到的范围,就在每个结果的range属性里面。通常要取得的还有,正则表达式中任意匹配到的范围。 可以通过 numberOfRanges 属性 和 rangeAt(_:) 方法,找出指定的范围。

range(at:)

Returns the result type that the range represents.

range(at:) 方法, 返回的结果就是对应的范围

Discussion

A result must have at least one range, but may optionally have more (for example, to represent regular expression capture groups).

Passing range(at:) the value 0 always returns the value of the the range property. Additional ranges, if any, will have indexes from 1 to numberOfRanges-1.

讨论下,

返回的结果,至少有一个范围。往往有更多,可选的。( 正则表达式捕获组,对应的)

 range(at:) 方法返回的第一个结果,就是 range 属性的值。如果有额外的,返回的结果对应的索引就是从 1 到 numberOfRanges-1   

引用下 苹果文档 ,

 range 0 是完全匹配到的范围,也是肯定能取到的。

然后从第1个到 第(numberOfRanges - 1)个的 ranges 数组中的值,就是分段,对应每一段正则匹配的结果。

使用之前给出的NSRange的取子串方法,就可以用 range 来取出匹配到的结果。

OC 代码

for match in matches {

       let stringToFormat = text.substring(with: match.rangeAt(2))!

       switch text.substring(with: match.rangeAt(1))! { 

       case "*" :

                 print("Make bold: '\(stringToFormat)'")

        case "_" :

                print("Make italic: '\(stringToFormat)'")

        default :

                break

                }

}

// 打印出

// Make bold: 'bold'

// Make italic: 'italic'

Swift 代码

for match in matches {

    let stringToFormat = text.substring(with: match.range(at: 2) )!

    switch text.substring(with: match.range(at: 1)  )! {

    case "*" :

            print("Make bold: '\(stringToFormat)'")

    case "_":

            print("Make italic: '\(stringToFormat)'")

    default: break

    }

}

// 打印出

// Make bold: 'bold'

// Make italic: 'italic'

对于基础的替换,直接用  stringByReplacingMatches(in:options:range:with:) 方法, String.replacingOccurences(of:with:options:) 的加强版 。上例中,不同的正则匹配 ( bold , italic),用不同的替换模版。

按照倒叙,循环访问这些匹配结果,这样就不会把后面的 match 范围搞乱。

var formattedText = text

Format: 

for match inmatches.reversed () {

    let template: String

    switch text.substring(with: match.range(at:1)  ) ?? ""{

    case "*":

         template  = "<strong>$2</strong>"

    case "_": 

        template = "<em>$2</em>"    

    default:    break Format

    }

    let matchRange = formattedText.range(from:match.range)!         // see above 

    let replacement = miniFormatter.replacementString( for: match, in: formattedText, offset: 0, template: template)

    formattedText.replaceSubrange( matchRange , with: replacement)

}

// 'formattedText' is now:

// "MiniFormatter handles bold and italic text."

通过自定义的模版,调用 miniFormatter.replacementString(for:in:...) 方法, 然后呢,每一个NSTextCheckingResult 实例会随之产生一个对应的替换字符串。

Expression and Matching Options   , 表达式与匹配选项

NSRegularExpression 是高度可配置的。弄一个实例,或者调用执行正则匹配的方法,都可以传不同选项的组合。

NSRegularExpression.Options

* .caseInsensitive : 字母大小写忽略。 开启字母大小写忽略的匹配,就是 i 标记

* .allowCommentsAndWhitespace : 允许注释、空格。 忽略 # 和句尾间任意的空格和注释。所以所以你可以尝试格式化和记录正则匹配,有了注释和空格,正则会好读一点。 等价于 x 标记

* .ignoreMetacharacters: 忽略元符号,忽略关键字。String.range(of:options:) 方法中的去正则化,与 .regularExpression 正则选项相反。这实际上就是正则变为简单的文本搜索,忽略所有的正则关键字和运算符。

* .dotMatchesLineSeparators: 句点分行匹配。允许 , 关键字匹配换行符以及其他字符。就是 s 标记。

* .anchorsMatchLines: 句中锚点匹配。允许 ^ (开始)和 $ (结束)关键字,匹配句中的开始和结束。而不仅仅是输入的整段的开始和结尾。就是 m 标记

* .useUnixLineSeparators, .useUnicodeWordBoundaries:  最后两项优化了更多特定的行和字的边界处理。Unix 行分隔符。

NSRegularExpression.MatchingOptions     正则表达式的匹配选项

一个 NSRegularExpression 正则表达式实例中,可以传入选项来调整匹配的方法。

* .anchored:   锚定的。仅匹配搜索范围的开头第一段。

* .withTransparentBounds: 超过界限。允许正则在搜索范围前,向前查找。反之,向后查找。还有单词的边界。(尽管不适用于,实际的匹配字符)

static var withTransparentBounds: NSRegularExpression.MatchingOptions

Specifies that matching may examine parts of the string beyond the bounds of the search range, for purposes such as word boundary detection, lookahead, etc. This constant has no effect if the search range contains the entire string. See enumerateMatches(in:options:range:using:) for a description of the constant in context.

苹果 链接:  https://developer.apple.com/documentation/foundation/nsregularexpression.matchingoptions


* .withoutAnchoringBounds : 无锚定界限。 让 ^ 和 $ 关键字仅匹配字符串的开始和结尾,而不是搜索范围的开始和结束。

* .reportCompletion ( 报告完成 ) , .reportProgress ( 报告进度 ):  这些参数选项仅在下节讲的部分匹配方法中有用。当正则查找完成了,或者是耗时的匹配上有进度,相应选项会通知 NSRegular​Expression 传入附加时间,调用枚举块。

Partial Matching       部分匹配

最后, NSRegular​Expression 最强大的特性之一是,仅扫描字符串中需要的部分。处理长文本,挺有用的。处理耗资源的正则匹配,也是。

不要用这两个方法 firstMatch(in:...) matches(in:...) , 调用 enumerateMatches(in:options:range:using:) ,用闭包处理对应的匹配。

func  enumerateMatches( instring : String, options:NSRegularExpression.MatchingOptions= [], range:NSRange,  usingblock:  (NSTextCheckingResult?,NSRegularExpression.MatchingFlags,UnsafeMutablePointer<ObjCBool>) ->Void)

苹果链接: https://developer.apple.com/documentation/foundation/nsregularexpression/1409687-enumeratematches

这个闭包接收三个参数,匹配的正则结果,一组标志选项, 一个布尔指针。 这个 bool 指针是一个只出参数,可以通过它在设定的时机停止处理。

可以用这个方法在 Dostoevsky 的 Karamazov 兄弟一书中, 查找开始的几个名字。名字遵从的规则是,首名,中间父姓 ( 例如: “Ivan Fyodorovitch” )

OC 代码

NSString * namePattern = @"([A-Z]\\S+)\\s+([A-Z]\\S+(vitch|vna))";

NSRegularExpression * nameRegex = [NSRegularExpression regularExpressionWithPattern: namePatternoptions: kNilOptionserror: &error];

NSString * bookString = ...

NSMutableSet * name = [NSMutableSet set ];

[nameRegex enumerateMatchesInString: bookString options: kNilOptions range: NSMakeRange(0 , [ bookString length ] ) usingBlock: ^( NSTextCheckingResult * result , NSMatchingFlags flags , BOOL * stop ){

    if ( result == nil ) return;

    NSString * name = [nameRegex replacementStringForResult : resultinString : bookStringoffset : 0 template: @"$1 $2" ];

    [names addObject: name ];

    // stop once we've found six unique names

    *stop = ( names.count==6);

} ] ;

Swift 代码

let nameRegex = try! NSRegularExpression( pattern: "([A-Z]\\S+)\\s+([A-Z]\\S+(vitch|vna))" )

let bookString = ...

var names:Set = []

nameRegex.enumerateMatches( in: bookString, range: bookString.nsrange ){

    ( result , _ , stopPointer )      in

    guard let result = result else { return }

    let name = nameRegex.replacementString( for: result , in:  bookString , offset : 0 , template: "$1 $2" )

    names.insert(name)

    // stop once we've found six unique names ,通过 Set 确保,6个不一样的名字文本 

    stopPointer.pointee = ObjCBool( names.count==6 )

}

// names.sorted():

// ["Adelaïda Ivanovna", "Alexey Fyodorovitch", "Dmitri Fyodorovitch",

// "Fyodor Pavlovitch", "Pyotr Alexandrovitch", "Sofya Ivanovna"]

通过这种途径,我们只需查找前 45 个匹配,而不是把全书中接近1300个名字都找一遍。性能显著提高。

一旦有所认识,NSRegularExpression 就会超级有用。除了 NSRegularExpression , 还有一个类 NSDataDetector . NSDataDetector 是一个用于识别有用信息的类,可以用来处理用户相关的文本,查找日期,地址与手机号码。通过Fundation 框架处理文本,NSRegularExpression 强大,健壮,有简洁的接口,也有深入




说明: 为了有意思一些, 我采取了意译。并加入了一些 Regex 细节与扩展资料。

文中 出现的 Swift 代码, 已校正到 Swift 4 .

谢谢观看

PS: 参考资料

文中的 Swift 代码,github 地址: https://github.com/dengV/regex_001

超好用的正则网站:101

熟悉的 ray wenderlich tutorial : 我升级了对应的Swift代码, github 链接

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容