15  Regular expressions

15.1 Introduction

在@sec-strings中,你学习了许多处理字符串的实用函数。 本章将重点介绍使用正则表达式(regular expressions)的函数,这是一种用于描述字符串模式的简洁而强大的语言。 术语“regular expression”有些拗口,因此大多数人将其简称为“regex”1或“regexp”。

本章将从正则表达式的基础知识以及数据分析中最实用的 stringr 函数开始。 随后将拓展你对模式匹配的认知,涵盖七个重要新主题(转义、锚定、字符类、简写字符类、量词、优先级和分组)。 接着我们将讨论 stringr 函数可处理的其他模式类型,以及允许调整正则表达式操作的各种“标志”。 最后将概述 tidyverse 和 base R 中其他可能使用正则表达式的场景。

15.1.1 Prerequisites

本章我们将使用来自 tidyverse 核心成员 stringr 和 tidyr 的正则表达式函数,以及 babynames 包的数据。

本章将结合使用简单的内联示例(帮助你理解基础概念)、婴儿姓名数据,以及来自 stringr 的三个字符向量:

  • fruit 包含80种水果的名称。
  • words 包含980个常见英语单词。
  • sentences 包含720个短句。

15.2 Pattern basics

我们将使用str_view()来理解正则表达式模式的工作原理。 在上一章中,我们使用str_view()来更好理解字符串与其打印表示形式之间的区别,现在我们将使用它的第二个参数——一个正则表达式。 当提供此参数时,str_view()将仅显示字符串向量中匹配的元素,用<>包围每个匹配项,并尽可能用蓝色高亮显示匹配部分。

最简单的模式由字母和数字组成,它们会精确匹配这些字符:

str_view(fruit, "berry")
#>  [6] │ bil<berry>
#>  [7] │ black<berry>
#> [10] │ blue<berry>
#> [11] │ boysen<berry>
#> [19] │ cloud<berry>
#> [21] │ cran<berry>
#> ... and 8 more

字母和数字会精确匹配,因此被称为字面字符(literal characters)。 而大多数标点符号(如., +, *, [, ], ?)具有特殊含义,被称为元字符(metacharacters)。 例如,.可以匹配任意字符2,所以"a."会匹配任何包含字母“a”且后接另一个字符的字符串:

str_view(c("a", "ab", "ae", "bd", "ea", "eab"), "a.")
#> [2] │ <ab>
#> [3] │ <ae>
#> [6] │ e<ab>

或者我们可以找出所有包含字母“a”、后接三个任意字母、最后接字母“e”的水果名称:

str_view(fruit, "a...e")
#>  [1] │ <apple>
#>  [7] │ bl<ackbe>rry
#> [48] │ mand<arine>
#> [51] │ nect<arine>
#> [62] │ pine<apple>
#> [64] │ pomegr<anate>
#> ... and 2 more

量词(Quantifiers)控制模式匹配的次数:

  • ? 使模式成为可选项(即匹配 0 次或 1 次)
  • + 允许模式重复(即至少匹配一次)
  • * 允许模式成为可选项或重复(即匹配任意次数,包括0次)
# ab? matches an "a", optionally followed by a "b".
str_view(c("a", "ab", "abb"), "ab?")
#> [1] │ <a>
#> [2] │ <ab>
#> [3] │ <ab>b

# ab+ matches an "a", followed by at least one "b".
str_view(c("a", "ab", "abb"), "ab+")
#> [2] │ <ab>
#> [3] │ <abb>

# ab* matches an "a", followed by any number of "b"s.
str_view(c("a", "ab", "abb"), "ab*")
#> [1] │ <a>
#> [2] │ <ab>
#> [3] │ <abb>

字符类(Character classes)[]定义,允许您匹配一组字符,例如[abcd]会匹配”a”、“b”、“c”或”d”。 您还可以使用^开头来反向匹配:[^abcd]会匹配除”a”、“b”、“c”、“d”以外的任何字符。 我们可以利用这个思路来查找包含被元音字母包围的”x”,或被辅音字母包围的”y”的单词:

str_view(words, "[aeiou]x[aeiou]")
#> [284] │ <exa>ct
#> [285] │ <exa>mple
#> [288] │ <exe>rcise
#> [289] │ <exi>st
str_view(words, "[^aeiou]y[^aeiou]")
#> [836] │ <sys>tem
#> [901] │ <typ>e

你可以使用交替符(alternation)|,在一个或多个备选模式中进行选择。 例如,以下模式会查找包含”apple”、“melon”或”nut”的水果,或者包含重复元音的水果。

str_view(fruit, "apple|melon|nut")
#>  [1] │ <apple>
#> [13] │ canary <melon>
#> [20] │ coco<nut>
#> [52] │ <nut>
#> [62] │ pine<apple>
#> [72] │ rock <melon>
#> ... and 1 more
str_view(fruit, "aa|ee|ii|oo|uu")
#>  [9] │ bl<oo>d orange
#> [33] │ g<oo>seberry
#> [47] │ lych<ee>
#> [66] │ purple mangost<ee>n

正则表达式非常紧凑且使用大量标点符号,因此初看可能令人难以应对且难以阅读。 别担心;通过练习你会逐渐掌握,简单的模式很快就会变得得心应手。 让我们通过练习一些实用的 stringr 函数来启动这个学习过程。

15.3 Key functions

既然你已经掌握了正则表达式的基础知识,现在让我们将其与一些 stringr 和 tidyr 函数结合使用。 在接下来的部分中,你将学习如何检测匹配项的存在与否、如何统计匹配次数、如何用固定文本替换匹配项,以及如何使用模式提取文本。

15.3.1 Detect matches

str_detect() 会返回一个逻辑向量:当模式匹配字符向量中的元素时返回 TRUE,否则返回 FALSE

str_detect(c("a", "b", "c"), "[aeiou]")
#> [1]  TRUE FALSE FALSE

由于 str_detect() 返回的逻辑向量与初始向量长度相同,它非常适合与 filter() 配合使用。 例如,以下代码可以找出所有包含小写字母 “x” 的热门姓名:

babynames |> 
  filter(str_detect(name, "x")) |> 
  count(name, wt = n, sort = TRUE)
#> # A tibble: 974 × 2
#>   name           n
#>   <chr>      <int>
#> 1 Alexander 665492
#> 2 Alexis    399551
#> 3 Alex      278705
#> 4 Alexandra 232223
#> 5 Max       148787
#> 6 Alexa     123032
#> # ℹ 968 more rows

我们也可以将str_detect()summarize()结合使用,配合sum()mean()函数:sum(str_detect(x, pattern))可统计匹配的观测值数量,而mean(str_detect(x, pattern))则能计算匹配的比例。 例如,以下代码片段计算并按年份细分了包含字母”x”的婴儿姓名3比例,并通过可视化展示。 从结果来看,这类名字近年来的受欢迎程度似乎急剧上升!

babynames |> 
  group_by(year) |> 
  summarize(prop_x = mean(str_detect(name, "x"))) |> 
  ggplot(aes(x = year, y = prop_x)) + 
  geom_line()

A time series showing the proportion of baby names that contain the letter x.  The proportion declines gradually from 8 per 1000 in 1880 to 4 per 1000 in  1980, then increases rapidly to 16 per 1000 in 2019.

有两个与str_detect()密切相关的函数:str_subset()str_which()str_subset()返回一个仅包含匹配字符串的字符向量。 str_which()返回一个给出匹配字符串位置的整数向量。

15.3.2 Count matches

在复杂度上比str_detect()更进一步的当属str_count():它不再返回简单的true/false判断,而是告诉你每个字符串中存在多少处匹配。

x <- c("apple", "banana", "pear")
str_count(x, "p")
#> [1] 2 0 1

需要注意的是,每处匹配都从上一处匹配的结尾开始,也就是说正则表达式匹配永远不会重叠。 例如在"abababa"中,模式"aba"会匹配多少次?正 则表达式的答案是2次而非3次:

str_count("abababa", "aba")
#> [1] 2
str_view("abababa", "aba")
#> [1] │ <aba>b<aba>

很自然地,我们会将str_count()mutate()结合使用。 下面这个示例通过str_count()配合字符类来统计每个名字中元音和辅音的数量。

babynames |> 
  count(name) |> 
  mutate(
    vowels = str_count(name, "[aeiou]"),
    consonants = str_count(name, "[^aeiou]")
  )
#> # A tibble: 97,310 × 4
#>   name          n vowels consonants
#>   <chr>     <int>  <int>      <int>
#> 1 Aaban        10      2          3
#> 2 Aabha         5      2          3
#> 3 Aabid         2      2          3
#> 4 Aabir         1      2          3
#> 5 Aabriella     5      4          5
#> 6 Aada          1      2          2
#> # ℹ 97,304 more rows

如果仔细观察,你会发现我们的计算存在一些问题:“Aaban”包含三个”a”,但我们的汇总结果只显示两个元音。 这是因为正则表达式区分大小写。 我们可以通过三种方式解决这个问题:

  • 在字符类中添加大写元音字母:str_count(name, "[aeiouAEIOU]")
  • 告知正则表达式忽略大小写:str_count(name, regex("[aeiou]", ignore_case = TRUE))。我们将在@sec-flags进一步讨论。
  • 使用str_to_lower()将姓名转换为小写:str_count(str_to_lower(name), "[aeiou]")

这种处理字符串的方法多样性相当典型 — 通常有多种途径可以实现目标,既可以通过让模式更复杂,也可以对字符串进行预处理。 如果某种方法遇到困难,转换思路并从不同角度解决问题通常会很有帮助。

就本例而言,由于我们需要对姓名应用两个函数,我认为先进行转换会更简单:

babynames |> 
  count(name) |> 
  mutate(
    name = str_to_lower(name),
    vowels = str_count(name, "[aeiou]"),
    consonants = str_count(name, "[^aeiou]")
  )
#> # A tibble: 97,310 × 4
#>   name          n vowels consonants
#>   <chr>     <int>  <int>      <int>
#> 1 aaban        10      3          2
#> 2 aabha         5      3          2
#> 3 aabid         2      3          2
#> 4 aabir         1      3          2
#> 5 aabriella     5      5          4
#> 6 aada          1      3          1
#> # ℹ 97,304 more rows

15.3.3 Replace values

除了检测和计数匹配项外,我们还可以使用str_replace()str_replace_all()来修改它们。 str_replace()替换第一个匹配项,而顾名思义,str_replace_all()会替换所有匹配项。

x <- c("apple", "pear", "banana")
str_replace_all(x, "[aeiou]", "-")
#> [1] "-ppl-"  "p--r"   "b-n-n-"

str_remove()str_remove_all()则是str_replace(x, pattern, "")的便捷快捷方式:

x <- c("apple", "pear", "banana")
str_remove_all(x, "[aeiou]")
#> [1] "ppl" "pr"  "bnn"

在进行数据清理时,这些函数很自然地与mutate()配合使用,通常需要反复应用它们来逐层剥离不一致的格式。

15.3.4 Extract variables

我们将讨论的最后一个函数是使用正则表达式将数据从某列提取到一个或多个新列中:separate_wider_regex()。 它是您在@sec-string-columns学过的separate_wider_position()separate_wider_delim()函数的同类函数。 这些函数属于 tidyr 包,因为它们作用于数据框的列,而非单个向量。

让我们创建一个简单数据集来演示其工作原理。 这里有一些源自babynames的数据,其中包含一组人员的姓名、性别和年龄,但格式相当奇怪4

df <- tribble(
  ~str,
  "<Sheryl>-F_34",
  "<Kisha>-F_45", 
  "<Brandon>-N_33",
  "<Sharon>-F_38", 
  "<Penny>-F_58",
  "<Justin>-M_41", 
  "<Patricia>-F_84", 
)

使用separate_wider_regex()提取这些数据时,我们只需构建一系列匹配每个片段的正则表达式。 若希望该片段的内容出现在输出中,我们需要为其命名:

df |> 
  separate_wider_regex(
    str,
    patterns = c(
      "<", 
      name = "[A-Za-z]+", 
      ">-", 
      gender = ".", "_", 
      age = "[0-9]+"
    )
  )
#> # A tibble: 7 × 3
#>   name    gender age  
#>   <chr>   <chr>  <chr>
#> 1 Sheryl  F      34   
#> 2 Kisha   F      45   
#> 3 Brandon N      33   
#> 4 Sharon  F      38   
#> 5 Penny   F      58   
#> 6 Justin  M      41   
#> # ℹ 1 more row

如果匹配失败,可以像使用separate_wider_delim()separate_wider_position()时那样,通过设置too_short = "debug"来排查问题。

15.3.5 Exercises

  1. 哪个婴儿名字包含最多元音? 哪个名字的元音比例最高? (提示:分母是什么?)

  2. "a/b/c/d/e"中的所有正斜杠替换为反斜杠。 如果尝试通过将所有反斜杠替换为正斜杠来撤销转换,会发生什么? (我们很快就会讨论这个问题。)

  3. 使用str_to_lower()实现一个简易版的str_replace_all()

  4. 创建一个能匹配贵国常见电话号码格式的正则表达式。

15.4 Pattern details

既然您已掌握模式语言的基础知识及其在 stringr 和 tidyr 函数中的运用,现在该深入了解更多细节了。 首先从转义(escaping)开始,它能让你匹配原本具有特殊含义的元字符。 接着,将学习锚点(anchors),用于匹配字符串的首尾位置。 然后,深入了解字符类(character classes)及其简写形式,从而匹配集合中的任意字符。 随后,你会学习量词(quantifiers)的完整细节,这些量词控制着模式的匹配次数。 接着必须讨论运算符优先级(operator precedence)与括号这一重要(但复杂)的主题。 最后以模式分组(grouping)组件的具体细节作为结束。

这里使用的术语是各组成部分的技术名称。 这些名称未必都能直观体现其功能,但若后续需要搜索更多细节时,了解正确术语将非常有帮助。

15.4.1 Escaping

为了匹配字面意义的.,你需要使用转义符(escape)来告知正则表达式按字面意义匹配元字符5。 和字符串一样,正则表达式使用反斜杠进行转义。 因此,要匹配一个.,你需要使用正则表达式\.。但这样会带来一个问题。 我们用字符串来表示正则表达式,而\在字符串中也用作转义符号。 所以要创建正则表达式\.,我们需要使用字符串"\\.",如下例所示。

# To create the regular expression \., we need to use \\.
dot <- "\\."

# But the expression itself only contains one \
str_view(dot)
#> [1] │ \.

# And this tells R to look for an explicit .
str_view(c("abc", "a.c", "bef"), "a\\.c")
#> [2] │ <a.c>

在本书中,我们通常不会给正则表达式加引号,例如\.。 如果需要强调实际需要输入的内容,则会为其添加引号和额外的转义符号,如"\\."

如果\在正则表达式中用作转义字符,那么如何匹配字面意义上的\呢? 你需要对它进行转义,构建出\\这样的正则表达式。 而为了构建这个正则表达式,你需要使用字符串,字符串本身也需要对\进行转义。 这意味着要匹配一个字面意义上的\,你必须写成"\\\\" — 需要四个反斜杠才能匹配一个!

x <- "a\\b"
str_view(x)
#> [1] │ a\b
str_view(x, "\\\\")
#> [1] │ a<\>b

或者,您可能会发现使用@sec-raw-strings介绍的原始字符串更简便。 这样可以避免一层转义:

str_view(x, r"{\\}")
#> [1] │ a<\>b

如果您需要匹配字面字符 ., $, |, *, +, ?, {, }, (, ),除了使用反斜杠转义外还有另一种选择:可以使用字符类:[.], [$], [|], … 所有这些都能匹配对应的字面值。

str_view(c("abc", "a.c", "a*c", "a c"), "a[.]c")
#> [2] │ <a.c>
str_view(c("abc", "a.c", "a*c", "a c"), ".[*]c")
#> [3] │ <a*c>

15.4.2 Anchors

默认情况下,正则表达式会匹配字符串的任意部分。 若需匹配字符串起始或结束位置,需要使用锚点(anchor)^ 匹配起始位置,$ 匹配结束位置:

str_view(fruit, "^a")
#> [1] │ <a>pple
#> [2] │ <a>pricot
#> [3] │ <a>vocado
str_view(fruit, "a$")
#>  [4] │ banan<a>
#> [15] │ cherimoy<a>
#> [30] │ feijo<a>
#> [36] │ guav<a>
#> [56] │ papay<a>
#> [74] │ satsum<a>

人们容易认为 $ 应该匹配字符串开头(因为美元金额通常这样书写),但这不符合正则表达式的规则。

若要强制正则表达式仅匹配完整字符串,需同时使用 ^$ 进行锚定:

str_view(fruit, "apple")
#>  [1] │ <apple>
#> [62] │ pine<apple>
str_view(fruit, "^apple$")
#> [1] │ <apple>

你也可以使用 \b 来匹配单词边界(即单词的开始或结束)。 这在配合 RStudio 的查找替换工具时特别有用。 例如,若要查找所有 sum() 的用法,可以通过搜索 \bsum\b 来避免匹配到 summarize, summary, rowsum 等函数:

x <- c("summary(x)", "summarize(df)", "rowsum(x)", "sum(x)")
str_view(x, "sum")
#> [1] │ <sum>mary(x)
#> [2] │ <sum>marize(df)
#> [3] │ row<sum>(x)
#> [4] │ <sum>(x)
str_view(x, "\\bsum\\b")
#> [4] │ <sum>(x)

当单独使用锚点时,会产生零宽度匹配:

str_view("abc", c("$", "^", "\\b"))
#> [1] │ abc<>
#> [2] │ <>abc
#> [3] │ <>abc<>

这有助于理解替换独立锚点时会发生的情况:

str_replace_all("abc", c("$", "^", "\\b"), "--")
#> [1] "abc--"   "--abc"   "--abc--"

15.4.3 Character classes

字符类(character class),或称字符集(character set),允许你匹配集合中的任意字符。 正如前面讨论的,你可以用[]来自定义集合:[abc]会匹配”a”、“b”或”c”,而[^abc]则匹配除”a”、“b”、“c”外的任意字符。 除了^之外,还有两个字符在[]内具有特殊含义:

  • - 用于定义范围,例如[a-z]匹配所有小写字母,[0-9]匹配任意数字。
  • \ 用于转义特殊字符,因此[\^\-\]]会匹配^, -]

以下是一些示例:

x <- "abcd ABCD 12345 -!@#%."
str_view(x, "[abc]+")
#> [1] │ <abc>d ABCD 12345 -!@#%.
str_view(x, "[a-z]+")
#> [1] │ <abcd> ABCD 12345 -!@#%.
str_view(x, "[^a-z0-9]+")
#> [1] │ abcd< ABCD >12345< -!@#%.>

# You need an escape to match characters that are otherwise
# special inside of []
str_view("a-b-c", "[a-c]")
#> [1] │ <a>-<b>-<c>
str_view("a-b-c", "[a\\-c]")
#> [1] │ <a><->b<-><c>

有些字符类因使用频率极高而拥有专属简写模式。 您已见过.,匹配除换行符外任意字符。 另外还有三组特别实用的对应简写6

  • \d 匹配任意数字;
    \D 匹配任意非数字字符。
  • \s 匹配任意空白字符(如空格、制表符、换行符);
    \S 匹配任意非空白字符。
  • \w 匹配任意“单词”字符(即字母和数字);
    \W 匹配任意“非单词”字符。

以下代码通过选取字母、数字和标点符号来演示这六种简写模式。

x <- "abcd ABCD 12345 -!@#%."
str_view(x, "\\d+")
#> [1] │ abcd ABCD <12345> -!@#%.
str_view(x, "\\D+")
#> [1] │ <abcd ABCD >12345< -!@#%.>
str_view(x, "\\s+")
#> [1] │ abcd< >ABCD< >12345< >-!@#%.
str_view(x, "\\S+")
#> [1] │ <abcd> <ABCD> <12345> <-!@#%.>
str_view(x, "\\w+")
#> [1] │ <abcd> <ABCD> <12345> -!@#%.
str_view(x, "\\W+")
#> [1] │ abcd< >ABCD< >12345< -!@#%.>

15.4.4 Quantifiers

量词(Quantifiers)用于控制模式的匹配次数。 在@sec-reg-basics中您已学习了?(匹配0或1次)、+(匹配1次或多次)和*(匹配0次或多次)。 例如,colou?r可匹配美式或英式拼写,\d+将匹配一个或多个数字,\s?则可选择性地匹配单个空白字符。 您还可以使用{}精确指定匹配次数:

  • {n} 精确匹配n次。
  • {n,} 至少匹配n次。
  • {n,m} 匹配n到m次。

15.4.5 Operator precedence and parentheses

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 Grouping and capturing

除了覆盖运算符优先级外,括号还有另一个重要作用:它们创建捕获组(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()会返回一个矩阵,因此处理起来不太方便7

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 Exercises

  1. 你如何匹配字面字符串 "'\?又该如何匹配 "$^$"

  2. 请解释为何这些模式都无法匹配\"\", "\\", "\\\"

  3. 基于 stringr::words 中的常见词汇库,创建正则表达式来找出所有符合以下条件的单词:

    1. 以“y”开头
    2. 不以“y”开头
    3. 以“x”结尾
    4. 恰好由三个字母组成(不要通过 str_length() 作弊!)
    5. 包含七个或更多字母
    6. 包含元音-辅音组合
    7. 连续包含至少两个元音-辅音组合
    8. 仅由重复的元音-辅音组合构成
  4. 请创建11个正则表达式,分别匹配下列单词的英式或美式拼写:airplane/aeroplane、aluminum/aluminium、analog/analogue、ass/arse、center/centre、defense/defence、donut/doughnut、gray/grey、modeling/modelling、skeptic/sceptic、summarize/summarise。 请尝试写出最简短的正则表达式!

  5. 将单词的首尾字母互换。 哪些互换后的字符串仍然是单词?

  6. 用文字描述下列正则表达式分别匹配什么内容(请仔细辨别每个条目是正则表达式还是用于定义正则表达式的字符串):

    1. ^.*$
    2. "\\{.+\\}"
    3. \d{4}-\d{2}-\d{2}
    4. "\\\\{4}"
    5. \..\..\..
    6. (.)\1\1
    7. "(..)\\1"
  7. 解答初学者正则表达式填字游戏:https://regexcrossword.com/challenges/beginner.

15.5 Pattern control

通过使用模式对象而非单纯字符串,可以对匹配细节实施额外控制。 这允许您控制所谓的正则表达式标志,并匹配各种类型的固定字符串,具体说明如下。

15.5.1 Regex flags

有多种设置可用于控制正则表达式的匹配细节。 这些设置在其它编程语言中通常被称为标志(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")
    #> ✖ Empty `string` provided.
    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。 该选项会调整模式语言的解析规则:忽略空格和换行符以及#后面的所有内容。 这样您就能通过注释和空白字符来提升复杂正则表达式的可读性8,如下例所示:

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 matches

您可以使用 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 Practice

接下来我们将通过解决几个半真实场景的问题来实践这些概念。 我们将讨论三种通用技巧:

  1. 通过创建简单的正向与反向验证来检查工作
  2. 将正则表达式与布尔代数结合使用
  3. 利用字符串操作构建复杂模式

15.6.1 Check your work

首先,让我们找出所有以”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

因为该模式也会匹配以 TheyThese 等单词开头的句子。 我们需要确保”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 Boolean operations

假设我们要查找仅包含辅音的单词。 一种方法是创建一个排除所有元音的字符类([^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 Creating a pattern with code

如果我们想找出所有提及颜色的句子该怎么办? 基本思路很简单:只需将交替符与单词边界结合使用。

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 Exercises

  1. 针对以下每个挑战,请尝试使用单一正则表达式和多重str_detect()调用组合这两种方式来解决。

    1. 找出所有以x开头或结尾的单词。
    2. 找出所有以元音开头、以辅音结尾的单词。
    3. 是否存在至少包含一个每种不同元音的单词?
  2. 构建模式来验证“i在e前,除非在c后”这条规则?

  3. colors() 包含许多修饰词,如“lightgray”和“darkblue”。 如何自动识别这些修饰词? (思考如何检测并移除被修饰的颜色名称)。

  4. 创建一个能匹配任何 base R 数据集的正则表达式。 您可以通过data()函数的特殊用法获取这些数据集列表:data(package = "datasets")$results[, "Item"]。 注意一些旧数据集是独立向量;它们包含带括号的分组“数据框”名称,因此需要去除括号内容。

15.7 Regular expressions in other places

与 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 enginePCRE engine。 值得庆幸的是,正则表达式的基础知识已经非常标准化,使用本书所教授的模式时几乎不会遇到差异。 只有当您开始依赖高级功能,例如复杂的 Unicode 字符范围或使用 (?…) 语法的特殊特性时,才需要注意这些区别。

15.8 Summary

由于每个标点符号都可能承载多重含义,正则表达式堪称最精炼的语言之一。 初学时确实令人困惑,但当你训练双眼读懂它们、让大脑理解它们之后,就能解锁这项在 R 及其他众多场景中都能运用的强大技能。

通过本章学习,你已掌握了最实用的 stringr 函数和正则表达式语言的核心组件,开启了成为正则表达式大师的征程。 此外还有丰富的拓展学习资源可供参考:

推荐从vignette("regular-expressions", package = "stringr")开始,该文档完整记录了 stringr 支持的所有语法规范。 另一个实用参考是https://www.regular-expressions.info/。 虽然不针对 R 语言,但能帮助你了解正则表达式的高阶特性及其底层原理。

需要了解的是,stringr 构建于 Marek Gagolewski 开发的 stringi 包之上。 如果在 stringr 中找不到所需功能,不妨查阅 stringi 包。 你会发现其使用方式与 stringr 一脉相承,很容易上手。

下一章我们将探讨与字符串密切相关的数据结构:因子(factors)。 因子用于表示 R 中的分类数据,即那些具有固定且已知的可能取值(由字符串向量标识)的数据类型。


  1. 您可以使用 hard-g (reg-x) 或 soft-g (rej-x) 来发音。↩︎

  2. 好的,除了\n之外的任何字符。↩︎

  3. 这意味着我们得到的是包含”x”的姓名比例;若想计算拥有带”x”名字的婴儿比例,则需要执行加权平均计算。↩︎

  4. 我们希望能向您保证不会在现实生活中遇到如此奇怪的情况,但遗憾的是在您的职业生涯中,很可能会遇到更加离奇的事情!↩︎

  5. 完整的元字符集是 .^$\|*+?{}[]()↩︎

  6. 请记住,要创建包含\d\s的正则表达式,您需要转义字符串的\,因此您需要输入"\\d""\\s"↩︎

  7. 主要是因为我们从未在本书中讨论矩阵!↩︎

  8. comments = TRUE 与原始字符串结合使用时特别有效,正如我们在这里使用的。↩︎