写在前面
本系列推文为《R for Data Science (2)》的中文翻译版本。所有内容都通过开源免费的方式上传至Github,欢迎大家参与贡献,详细信息见:
Books-zh-cn 项目介绍:
Books-zh-cn:开源免费的中文书籍社区
r4ds-zh-cn Github 地址:
https://github.com/Books-zh-cn/r4ds-zh-cn
r4ds-zh-cn 网站地址:
https://books-zh-cn.github.io/r4ds-zh-cn/
目录
-
15.4 模式细节
-
15.5 模式控制
-
15.6 实践
-
15.7 其他地方的正则表达式
-
15.8 总结
15.4.4 量词
量词(Quantifiers) 用于控制模式的匹配次数。 在Section15.2中您已学习了?(匹配0或1次)、+(匹配1次或多次)和*(匹配0次或多次)。 例如,colou?r可匹配美式或英式拼写,\d+将匹配一个或多个数字,\s?则可选择性地匹配单个空白字符。 您还可以使用{}精确指定匹配次数:
-
{n}精确匹配n次。 -
{n,}至少匹配n次。 -
{n,m}匹配n到m次。
15.4.5 运算符优先级和括号
ab+会匹配什么? 是匹配一个"a"后接一个或多个"b",还是匹配任意次重复的"ab"? 而^a|b$又会匹配什么? 是匹配完整的字符串"a"或完整的字符串"b",还是匹配以"a"开头的字符串或以"b"结尾的字符串?
这些问题的答案取决于运算符优先级,类似于你在学校可能学过的PEMDAS或BEDMAS规则。 你知道a + b * c等价于a + (b * c)而非(a + b) * c,因为*的优先级高于+:需要先计算*再计算+。
同样地,正则表达式也有自己的优先级规则:量词具有高优先级,而交替符具有低优先级。 这意味着ab+等价于a(b+),而^a|b$等价于(^a)|(b$)。 就像代数运算一样,你可以使用括号来改变常规顺序。 但与代数不同的是,你不太可能记住正则表达式的优先级规则,因此请尽管自由地使用括号。
15.4.6 分组和捕获
除了覆盖运算符优先级外,括号还有另一个重要作用:它们创建 捕获组(capturing groups) ,使你能够使用匹配中的子组件。
使用捕获组的第一种方法是通过 反向引用(back reference) 在匹配中回溯:\1 指向第一个括号内的匹配内容,\2 指向第二个括号,依此类推。 例如,以下模式可以找到所有包含重复字母对的水果:
str_view(fruit, "(..)\\1")
#> [4] │ b<anan>a
#> [20] │ <coco>nut
#> [22] │ <cucu>mber
#> [41] │ <juju>be
#> [56] │ <papa>ya
#> [73] │ s<alal> berry
这个模式可以找出所有以相同字母对开头和结尾的单词:
str_view(words, "^(..).*\\1$")
#> [152] │ <church>
#> [217] │ <decide>
#> [617] │ <photograph>
#> [699] │ <require>
#> [739] │ <sense>
你同样可以在str_replace()中使用反向引用。 例如,以下代码可以调换句子中第二个和第三个单词的顺序:
sentences |>
str_replace("(\\w+) (\\w+) (\\w+)", "\\1 \\3 \\2") |>
str_view()
#> [1] │ The canoe birch slid on the smooth planks.
#> [2] │ Glue sheet the to the dark blue background.
#> [3] │ It's to easy tell the depth of a well.
#> [4] │ These a days chicken leg is a rare dish.
#> [5] │ Rice often is served in round bowls.
#> [6] │ The of juice lemons makes fine punch.
#> ... and 714 more
如果想要提取每个分组的匹配内容,可以使用str_match()。 但str_match()会返回一个矩阵,因此处理起来不太方便:
sentences |>
str_match("the (\\w+) (\\w+)") |>
head()
#> [,1] [,2] [,3]
#> [1,] "the smooth planks" "smooth" "planks"
#> [2,] "the sheet to" "sheet" "to"
#> [3,] "the depth of" "depth" "of"
#> [4,] NA NA NA
#> [5,] NA NA NA
#> [6,] NA NA NA
你可以将其转换为tibble并为列命名:
sentences |>
str_match("the (\\w+) (\\w+)") |>
as_tibble(.name_repair = "minimal") |>
set_names("match", "word1", "word2")
#> # A tibble: 720 × 3
#> match word1 word2
#> <chr> <chr> <chr>
#> 1 the smooth planks smooth planks
#> 2 the sheet to sheet to
#> 3 the depth of depth of
#> 4 <NA> <NA> <NA>
#> 5 <NA> <NA> <NA>
#> 6 <NA> <NA> <NA>
#> # ℹ 714 more rows
但这样你基本上就重建了自己版本的separate_wider_regex()。 实际上,在底层实现中,separate_wider_regex()会将你的模式向量转换为使用分组来捕获命名组件的单个正则表达式。
偶尔你会希望使用括号但不创建匹配组。 这时可以使用(?:)创建非捕获组。
x <- c("a gray cat", "a grey dog")
str_match(x, "gr(e|a)y")
#> [,1] [,2]
#> [1,] "gray" "a"
#> [2,] "grey" "e"
str_match(x, "gr(?:e|a)y")
#> [,1]
#> [1,] "gray"
#> [2,] "grey"
15.4.7 练习
-
你如何匹配字面字符串
"'\?又该如何匹配"$^$"? -
请解释为何这些模式都无法匹配
\:"\","\\","\\\"。 -
基于
stringr::words中的常见词汇库,创建正则表达式来找出所有符合以下条件的单词:a. 以"y"开头
b. 不以"y"开头
c. 以"x"结尾
d. 恰好由三个字母组成(不要通过
str_length()作弊!)e. 包含七个或更多字母
f. 包含元音-辅音组合
g. 连续包含至少两个元音-辅音组合
h. 仅由重复的元音-辅音组合构成
-
请创建11个正则表达式,分别匹配下列单词的英式或美式拼写:airplane/aeroplane、aluminum/aluminium、analog/analogue、ass/arse、center/centre、defense/defence、donut/doughnut、gray/grey、modeling/modelling、skeptic/sceptic、summarize/summarise。 请尝试写出最简短的正则表达式!
-
将单词的首尾字母互换。 哪些互换后的字符串仍然是单词?
-
用文字描述下列正则表达式分别匹配什么内容(请仔细辨别每个条目是正则表达式还是用于定义正则表达式的字符串):
a.
^.*$b.
"\\{.+\\}"c.
\d{4}-\d{2}-\d{2}d.
"\\\\{4}"e.
\..\..\..f.
(.)\1\1g.
"(..)\\1" -
解答初学者正则表达式填字游戏:https://regexcrossword.com/challenges/beginner.
15.5 模式控制
通过使用模式对象而非单纯字符串,可以对匹配细节实施额外控制。 这允许您控制所谓的正则表达式标志,并匹配各种类型的固定字符串,具体说明如下。
15.5.1 正则表达式标志
有多种设置可用于控制正则表达式的匹配细节。 这些设置在其它编程语言中通常被称为 标志(flags) 。 在stringr中,您可以通过将模式包裹在regex()函数中来使用这些设置。 其中最实用的标志大概是ignore_case = TRUE,因为它允许字符匹配其大写或小写形式:
bananas <- c("banana", "Banana", "BANANA")
str_view(bananas, "banana")
#> [1] │ <banana>
str_view(bananas, regex("banana", ignore_case = TRUE))
#> [1] │ <banana>
#> [2] │ <Banana>
#> [3] │ <BANANA>
如果您需要处理大量多行字符串(即包含\n的字符串),dotall``multiline参数也会很有用:
-
dotall = TRUE允许.匹配所有字符,包括\n:x <- "Line 1\nLine 2\nLine 3" str_view(x, ".Line") str_view(x, regex(".Line", dotall = TRUE)) #> [1] │ Line 1< #> │ Line> 2< #> │ Line> 3 -
multiline = TRUE使得^和$分别匹配每行的开头和结尾,而非整个字符串的开头和结尾:x <- "Line 1\nLine 2\nLine 3" str_view(x, "^Line") #> [1] │ <Line> 1 #> │ Line 2 #> │ Line 3 str_view(x, regex("^Line", multiline = TRUE)) #> [1] │ <Line> 1 #> │ <Line> 2 #> │ <Line> 3
最后,如果您正在编写复杂的正则表达式,并且担心将来可能无法理解它,可以尝试使用comments = TRUE。 该选项会调整模式语言的解析规则:忽略空格和换行符以及#后面的所有内容。 这样您就能通过注释和空白字符来提升复杂正则表达式的可读性,如下例所示:
phone <- regex(
r"(
\(? # optional opening parens
(\d{3}) # area code
[)\-]? # optional closing parens or dash
\ ? # optional space
(\d{3}) # another three numbers
[\ -]? # optional space or dash
(\d{4}) # four more numbers
)",
comments = TRUE
)
str_extract(c("514-791-8141", "(123) 456 7890", "123456"), phone)
#> [1] "514-791-8141" "(123) 456 7890" NA
如果您使用注释并想要匹配空格、换行符或#,则需要使用\对其进行转义。
15.5.2 固定正则表达式
您可以使用 fixed() 来避开正则表达式规则:
str_view(c("", "a", "."), fixed("."))
#> [3] │ <.>
fixed() 还具备忽略大小写的能力:
str_view("x X", "X")
#> [1] │ x <X>
str_view("x X", fixed("X", ignore_case = TRUE))
#> [1] │ <x> <X>
如果您处理的是非英语文本,可能会更倾向于使用 coll() 而非 fixed(),因为它能根据您指定的 locale 实现完整的大小写规则。 有locales的更多详细信息,请参阅 Section 14.6。
str_view("i İ ı I", fixed("İ", ignore_case = TRUE))
#> [1] │ i <İ> ı I
str_view("i İ ı I", coll("İ", ignore_case = TRUE, locale = "tr"))
#> [1] │ <i> <İ> ı I
15.6 实践
接下来我们将通过解决几个半真实场景的问题来实践这些概念。 我们将讨论三种通用技巧:
-
通过创建简单的正向与反向验证来检查工作
-
将正则表达式与布尔代数结合使用
-
利用字符串操作构建复杂模式
15.6.1 检查你的工作
首先,让我们找出所有以"The"开头的句子。 仅使用 ^ 锚点是不够的:
str_view(sentences, "^The")
#> [1] │ <The> birch canoe slid on the smooth planks.
#> [4] │ <The>se days a chicken leg is a rare dish.
#> [6] │ <The> juice of lemons makes fine punch.
#> [7] │ <The> box was thrown beside the parked truck.
#> [8] │ <The> hogs were fed chopped corn and garbage.
#> [11] │ <The> boy was there when the sun rose.
#> ... and 271 more
因为该模式也会匹配以 They 或 These 等单词开头的句子。 我们需要确保"e"是该单词的最后一个字母,可以通过添加单词边界来实现:
str_view(sentences, "^The\\b")
#> [1] │ <The> birch canoe slid on the smooth planks.
#> [6] │ <The> juice of lemons makes fine punch.
#> [7] │ <The> box was thrown beside the parked truck.
#> [8] │ <The> hogs were fed chopped corn and garbage.
#> [11] │ <The> boy was there when the sun rose.
#> [13] │ <The> source of the huge river is the clear spring.
#> ... and 250 more
那么如何查找所有以代词开头的句子呢?
str_view(sentences, "^She|He|It|They\\b")
#> [3] │ <It>'s easy to tell the depth of a well.
#> [15] │ <He>lp the woman get back to her feet.
#> [27] │ <He>r purse was full of useless trash.
#> [29] │ <It> snowed, rained, and hailed the same morning.
#> [63] │ <He> ran half way to the hardware store.
#> [90] │ <He> lay prone and hardly moved a limb.
#> ... and 57 more
快速检查结果发现存在一些错误匹配。 这是因为我们忘了使用括号:
str_view(sentences, "^(She|He|It|They)\\b")
#> [3] │ <It>'s easy to tell the depth of a well.
#> [29] │ <It> snowed, rained, and hailed the same morning.
#> [63] │ <He> ran half way to the hardware store.
#> [90] │ <He> lay prone and hardly moved a limb.
#> [116] │ <He> ordered peach pie with ice cream.
#> [127] │ <It> caught its hind paw in a rusty trap.
#> ... and 51 more
你可能会想,如果错误匹配没有出现在前几个结果中,该如何发现这类错误。 有个好方法是创建一些正向和反向测试用例,用它们来验证模式是否符合预期:
pos <- c("He is a boy", "She had a good time")
neg <- c("Shells come from the sea", "Hadley said 'It's a great day'")
pattern <- "^(She|He|It|They)\\b"
str_detect(pos, pattern)
#> [1] TRUE TRUE
str_detect(neg, pattern)
#> [1] FALSE FALSE
通常想出合适的正向用例比反向用例要容易得多,因为需要经过大量练习才能熟练运用正则表达式来预判自己的薄弱环节。 尽管如此,反向用例仍然很有用:在解决问题过程中,你可以逐步积累自己出错的案例,确保不会重复犯同样的错误。
15.6.2 布尔运算
假设我们要查找仅包含辅音的单词。 一种方法是创建一个排除所有元音的字符类([^aeiou]),允许其匹配任意数量的字母([^aeiou]+),然后通过首尾锚定强制匹配整个字符串(^[^aeiou]+$):
str_view(words, "^[^aeiou]+$")
#> [123] │ <by>
#> [249] │ <dry>
#> [328] │ <fly>
#> [538] │ <mrs>
#> [895] │ <try>
#> [952] │ <why>
但通过转换问题视角可以让解决过程更简单。 我们可以寻找不包含任何元音的单词,而非直接寻找仅包含辅音的单词:
str_view(words[!str_detect(words, "[aeiou]")])
#> [1] │ by
#> [2] │ dry
#> [3] │ fly
#> [4] │ mrs
#> [5] │ try
#> [6] │ why
当处理逻辑组合(特别是涉及"与"或"非"的情况)时,这是种实用技巧。 例如,要查找所有同时包含"a"和"b"的单词。 由于正则表达式没有内置"与"运算符,我们只能通过寻找包含"a"后接"b",或"b"后接"a"的单词来实现:
str_view(words, "a.*b|b.*a")
#> [2] │ <ab>le
#> [3] │ <ab>out
#> [4] │ <ab>solute
#> [62] │ <availab>le
#> [66] │ <ba>by
#> [67] │ <ba>ck
#> ... and 24 more
更简单的方法是组合两次str_detect()的调用结果:
words[str_detect(words, "a") & str_detect(words, "b")]
#> [1] "able" "about" "absolute" "available" "baby" "back"
#> [7] "bad" "bag" "balance" "ball" "bank" "bar"
#> [13] "base" "basis" "bear" "beat" "beauty" "because"
#> [19] "black" "board" "boat" "break" "brilliant" "britain"
#> [25] "debate" "husband" "labour" "maybe" "probable" "table"
如果想检查是否存在包含所有元音字母的单词呢? 若使用模式匹配,需要生成5! (120)种不同组合:
words[str_detect(words, "a.*e.*i.*o.*u")]
# ...
words[str_detect(words, "u.*o.*i.*e.*a")]
更简单的方式是组合五次str_detect()调用:
words[
str_detect(words, "a") &
str_detect(words, "e") &
str_detect(words, "i") &
str_detect(words, "o") &
str_detect(words, "u")
]
#> character(0)
总之,如果构建单一正则表达式时遇到困难,不妨退一步思考:是否可以将问题拆解为若干子问题,在进入下一步之前逐个攻克这些小型挑战。
15.6.3 使用代码创建模式
如果我们想找出所有提及颜色的句子该怎么办? 基本思路很简单:只需将交替符与单词边界结合使用。
str_view(sentences, "\\b(red|green|blue)\\b")
#> [2] │ Glue the sheet to the dark <blue> background.
#> [26] │ Two <blue> fish swam in the tank.
#> [92] │ A wisp of cloud hung in the <blue> air.
#> [148] │ The spot on the blotter was made by <green> ink.
#> [160] │ The sofa cushion is <red> and of light weight.
#> [174] │ The sky that morning was clear and bright <blue>.
#> ... and 20 more
但随着颜色数量的增加,手动构建这个模式很快就会变得繁琐。 如果能把颜色存储在向量中岂不是更好?
rgb <- c("red", "green", "blue")
事实上,我们可以做到! 只需要用 str_c() 和 str_flatten() 根据向量创建模式即可:
str_c("\\b(", str_flatten(rgb, "|"), ")\\b")
#> [1] "\\b(red|green|blue)\\b"
如果拥有更全面的颜色列表,我们就能让这个模式更完善。 可以从 R 语言绘图功能内置的颜色列表入手:
str_view(colors())
#> [1] │ white
#> [2] │ aliceblue
#> [3] │ antiquewhite
#> [4] │ antiquewhite1
#> [5] │ antiquewhite2
#> [6] │ antiquewhite3
#> ... and 651 more
但首先需要剔除带数字编号的变体:
cols <- colors()
cols <- cols[!str_detect(cols, "\\d")]
str_view(cols)
#> [1] │ white
#> [2] │ aliceblue
#> [3] │ antiquewhite
#> [4] │ aquamarine
#> [5] │ azure
#> [6] │ beige
#> ... and 137 more
接着将其转换成一个巨型模式。 此处不展示该模式(因其过于庞大),但可以看到其运行效果:
pattern <- str_c("\\b(", str_flatten(cols, "|"), ")\\b")
str_view(sentences, pattern)
#> [2] │ Glue the sheet to the dark <blue> background.
#> [12] │ A rod is used to catch <pink> <salmon>.
#> [26] │ Two <blue> fish swam in the tank.
#> [66] │ Cars and busses stalled in <snow> drifts.
#> [92] │ A wisp of cloud hung in the <blue> air.
#> [112] │ Leaves turn <brown> and <yellow> in the fall.
#> ... and 57 more
这个例子中,cols 仅包含数字和字母,因此无需担心元字符的问题。 但一般来说,只要是根据现有字符串创建模式,最好先通过 str_escape() 处理以确保按字面意义匹配。
15.6.4 练习
-
针对以下每个挑战,请尝试使用单一正则表达式和多重
str_detect()调用组合这两种方式来解决。a. 找出所有以
x开头或结尾的单词。b. 找出所有以元音开头、以辅音结尾的单词。
c. 是否存在至少包含一个每种不同元音的单词?
-
构建模式来验证"i在e前,除非在c后"这条规则?
-
colors()包含许多修饰词,如"lightgray"和"darkblue"。 如何自动识别这些修饰词? (思考如何检测并移除被修饰的颜色名称)。 -
创建一个能匹配任何 base R 数据集的正则表达式。 您可以通过
data()函数的特殊用法获取这些数据集列表:data(package = "datasets")$results[, "Item"]。 注意一些旧数据集是独立向量;它们包含带括号的分组"数据框"名称,因此需要去除括号内容。
15.7 其他地方的正则表达式
与 stringr 和 tidyr 函数类似,在 R 语言中还有许多其他场景可以使用正则表达式。 以下章节将介绍在更广泛的 tidyverse 生态系统和 base R 中其他一些实用函数。
15.7.1 tidyverse
还有三个特别实用的场景可能会用到正则表达式:
-
matches(pattern)可选取所有变量名符合指定模式的变量。 这是一个"tidyselect"函数,可在任何 tidyverse 函数中用于变量选择(例如select()、rename_with()和across())。 -
pivot_longer()的names_pattern参数接收正则表达式向量,其用法类似于separate_wider_regex()。 当需要从具有复杂结构的变量名中提取数据时特别有用。 -
separate_longer_delim()和separate_wider_delim()中的delim参数通常匹配固定字符串,但使用regex()可使其匹配模式。 例如,若需要匹配可能跟随空格的逗号(即regex(", ?")),这个功能就非常实用。
15.7.2 Base R
apropos(pattern) 会搜索全局环境中所有符合指定模式的对象。 当您不太确定某个函数的具体名称时,这个功能非常实用:
apropos("replace")
#> [1] "%+replace%" "replace" "replace_na"
#> [4] "replace_theme" "setReplaceMethod" "str_replace"
#> [7] "str_replace_all" "str_replace_na" "theme_replace"
list.files(path, pattern) 能列出指定路径中所有匹配正则表达式模式的文件。 例如,您可以通过以下方式找到当前目录下所有的 R Markdown 文件:
head(list.files(pattern = "\\.Rmd$"))
#> character(0)
需要注意的是,base R 使用的模式语言与 stringr 稍有差异。 这是因为 stringr 构建于 stringi package 之上,而 stringi 又基于 ICU engine 引擎 开发;base R 函数则根据是否设置perl = TRUE 分别采用 TRE engine 或 PCRE engine。 值得庆幸的是,正则表达式的基础知识已经非常标准化,使用本书所教授的模式时几乎不会遇到差异。 只有当您开始依赖高级功能,例如复杂的 Unicode 字符范围或使用 (?...) 语法的特殊特性时,才需要注意这些区别。
15.8 总结
由于每个标点符号都可能承载多重含义,正则表达式堪称最精炼的语言之一。 初学时确实令人困惑,但当你训练双眼读懂它们、让大脑理解它们之后,就能解锁这项在 R 及其他众多场景中都能运用的强大技能。
通过本章学习,你已掌握了最实用的 stringr 函数和正则表达式语言的核心组件,开启了成为正则表达式大师的征程。 此外还有丰富的拓展学习资源可供参考:
推荐从vignette("regular-expressions", package = "stringr")开始,该文档完整记录了 stringr 支持的所有语法规范。 另一个实用参考是https://www.regular-expressions.info/。 虽然不针对 R 语言,但能帮助你了解正则表达式的高阶特性及其底层原理。
需要了解的是,stringr 构建于 Marek Gagolewski 开发的 stringi 包之上。 如果在 stringr 中找不到所需功能,不妨查阅 stringi 包。 你会发现其使用方式与 stringr 一脉相承,很容易上手。
下一章我们将探讨与字符串密切相关的数据结构:因子(factors)。 因子用于表示 R 中的分类数据,即那些具有固定且已知的可能取值(由字符串向量标识)的数据类型。
-
您可以使用 hard-g (reg-x) 或 soft-g (rej-x) 来发音。
-
好的,除了
\n之外的任何字符。 -
这意味着我们得到的是包含"x"的姓名 比例;若想计算拥有带"x"名字的婴儿比例,则需要执行加权平均计算。
-
我们希望能向您保证不会在现实生活中遇到如此奇怪的情况,但遗憾的是在您的职业生涯中,很可能会遇到更加离奇的事情!
-
完整的元字符集是
.^$\|*+?{}[]() -
请记住,要创建包含
\d或\s的正则表达式,您需要转义字符串的\,因此您需要输入"\\d"或"\\s"。 -
主要是因为我们从未在本书中讨论矩阵!
-
comments = TRUE与原始字符串结合使用时特别有效,正如我们在这里使用的。
--------------- 本章结束 ---------------
本期翻译贡献:
@TigerZ生信宝库
注:本文已开启快捷转载,欢迎大家转载,只需标明文章出处即可。