9.4 工具
我们已经掌握了正则表达式的基础知识,现在是时候学习如何应用它们来解决实际问题。我们将在本节中学习多种 stringr 函数,它们可以:
确定与某种模式相匹配的字符串;
找出匹配的位置;
提取出匹配的内容;
使用新值替换匹配内容;
基于匹配拆分字符串。
9.4.1 匹配检测
要想确定一个字符向量能否匹配一种模式,可以使用 str_detect() 函数。它返回一个与输入向量具有同样长度的逻辑向量:
# library(tidyverse) 别忘了 x <- c("apple", "banana", "pear") str_detect(x, "e") > [1] TRUE FALSE TRUE
从数学意义上来说,逻辑向量中的 FALSE 为 0,TRUE 为 1。这使得在匹配特别大的向量时,sum() 和 mean() 函数能够发挥更大的作用:
# 有多少个以t开头的常用单词? sum(str_detect(words, "^t")) > [1] 65 # 以元音字母结尾的常用单词的比例是多少? mean(str_detect(words, "[aeiou]$")) > [1] 0.277
当逻辑条件非常复杂时(例如,匹配 a 或 b,但不匹配 c,除非 d 成立),一般来说,相对于创建单个正则表达式,使用逻辑运算符将多个 str_detect() 调用组合起来会更容易。例如,以下两种方法均可找出不包含元音字母的所有单词:
# 找出至少包含一个元音字母的所有单词,然后取反 no_vowels_1 <- !str_detect(words, "[aeiou]") # 找出仅包含辅音字母(非元音字母)的所有单词 no_vowels_2 <- str_detect(words, "^[^aeiou]+$") identical(no_vowels_1, no_vowels_2) > [1] TRUE
很明显第一种方法更容易理解。如果正则表达式过于复杂,则应该将其分解为几个更小的子表达式,将每个子表达式的匹配结果赋给一个变量,并使用逻辑运算组合起来。
str_detect() 函数的一种常见用法是选取出匹配某种模式的元素。你可以通过逻辑取子集方式来完成这种操作,也可以使用便捷的 str_subset() 包装器函数:
words[str_detect(words, "x$")] > [1] "box" "sex" "six" "tax" str_subset(words, "x$") > [1] "box" "sex" "six" "tax"
然而,字符串通常会是数据框的一列,此时我们可以使用 filter 操作:
df <- tibble( word = words, i = seq_along(word) #seq_along(along.with):创建开始于1,步长为1,与向量长度相等的数字序列 ) df %>% filter(str_detect(words, "x$")) > df %>% + filter(str_detect(words,"x$")) # A tibble: 4 x 2 word i <chr> <int> 1 box 108 2 sex 747 3 six 772 4 tax 841
str_detect() 函数的一种变体是 str_count(),后者不是简单地返回是或否,而是返回字符串中匹配的数量:
x <- c("apple", "banana", "pear") str_count(x, "a") > [1] 1 3 1 # 平均来看,每个单词中有多少个元音字母? mean(str_count(words, "[aeiou]")) > [1] 1.99
str_count() 也完全可以同 mutate() 函数一同使用:
df %>% mutate( vowels = str_count(word, "[aeiou]"), consonants = str_count(word, "[^aeiou]") ) > df %>% + mutate( + vowels = str_count(word, "[aeiou]"), + consonants = str_count(word, "[^aeiou]") + ) # A tibble: 980 x 4 word i vowels consonants <chr> <int> <int> <int> 1 a 1 1 0 2 able 2 2 2 3 about 3 3 2 4 absolute 4 4 4 5 accept 5 2 4 6 account 6 3 4 7 achieve 7 4 3 8 across 8 2 4 9 act 9 1 2 10 active 10 3 3 # ... with 970 more rows
注意,匹配从来不会重叠。例如,在 "abababa" 中,模式 "aba" 会匹配多少次?正则表达 式会告诉你是 2 次,而不是 3 次:
str_count("abababa", "aba") > [1] 2 str_view_all("abababa", "aba")
9.4.2 提取匹配内容
要想提取匹配的实际文本,我们可以使用 str_extract() 函数。我们将使用维基百科上的 Harvard sentences,这个数据集是用来测试 VOIP 系统的,但也可以用来练习正则表达式。这个数据集的全名是 stringr::sentences:
length(sentences) > [1] 720 head(sentences) > head(sentences) [1] "The birch canoe slid on the smooth planks." [2] "Glue the sheet to the dark blue background." [3] "It's easy to tell the depth of a well." [4] "These days a chicken leg is a rare dish." [5] "Rice is often served in round bowls." [6] "The juice of lemons makes fine punch."
假设我们想要找出包含一种颜色的所有句子。首先,我们需要创建一个颜色名称向量,然后将其转换成一个正则表达式:
colors <- c( "red", "orange", "yellow", "green", "blue", "purple" ) color_match <- str_c(colors, collapse = "|") #collapse 分隔符 color_match > [1] "red|orange|yellow|green|blue|purple"
注意,str_extract() 只提取第一个匹配。我们可以先选取出具有多于一种匹配的所有句子,然后就可以很容易地看到更多匹配:
more <- sentences[str_count(sentences, color_match) > 1] str_view_all(more, color_match)
str_extract(more, color_match) #只提取第一个匹配 > [1] "blue" "green" "orange"
这是 stringr 函数的一种通用模式,因为单个匹配可以使用更简单的数据结构。要想得到所有匹配,可以使用 str_extract_all() 函数,它会返回一个列表:
str_extract_all(more, color_match) > str_extract_all(more, color_match) [[1]] [1] "blue" "red" [[2]] [1] "green" "red" [[3]] [1] "orange" "red"
如果设置了 simplify = TRUE,那么 str_extract_all() 会返回一个矩阵,其中较短的匹配会扩展到与最长的匹配具有同样的长度:
str_extract_all(more, color_match, simplify = TRUE) > str_extract_all(more, color_match, simplify = TRUE) [,1] [,2] [1,] "blue" "red" [2,] "green" "red" [3,] "orange" "red" x <- c("a", "a b", "a b c") str_extract_all(x, "[a-z]", simplify = TRUE) > str_extract_all(x, "[a-z]", simplify = TRUE) [,1] [,2] [,3] [1,] "a" "" "" [2,] "a" "b" "" [3,] "a" "b" "c"
9.4.3 分组匹配
本章前面讨论了括号在正则表达式中的用法,它可以阐明优先级,还能对正则表达式进行分组,分组可以在匹配时回溯引用。还可以使用括号来提取一个复杂匹配的各个部分。举例来说,假设我们想从句子中提取出名词。我们先进行一种启发式实验,找出跟在 a 或 the 后面的所有单词。因为使用正则表达式定义“单词”有一点难度,所以我们使用一种简单的近似定义——至少有 1 个非空格字符的字符序列:
noun <- "(a|the) ([^ ]+)" has_noun <- sentences %>% str_subset(noun) %>% head(10) > has_noun [1] "The birch canoe slid on the smooth planks." [2] "Glue the sheet to the dark blue background." [3] "It's easy to tell the depth of a well." [4] "These days a chicken leg is a rare dish." [5] "The box was thrown beside the parked truck." [6] "The boy was there when the sun rose." [7] "The source of the huge river is the clear spring." [8] "Kick the ball straight and follow through." [9] "Help the woman get back to her feet." [10] "A pot of tea helps to pass the evening." has_noun %>% str_extract(noun) > has_noun %>% + str_extract(noun) [1] "the smooth" "the sheet" "the depth" "a chicken" "the parked" [6] "the sun" "the huge" "the ball" "the woman" "a helps"
str_extract() 函数可以给出完整匹配;str_match() 函数则可以给出每个独立分组。str_ match() 返回的不是字符向量,而是一个矩阵,其中一列是完整匹配,后面的列是每个分组的匹配:
has_noun %>% str_match(noun) > has_noun %>% + str_match(noun) [,1] [,2] [,3] [1,] "the smooth" "the" "smooth" [2,] "the sheet" "the" "sheet" [3,] "the depth" "the" "depth" [4,] "a chicken" "a" "chicken" [5,] "the parked" "the" "parked" [6,] "the sun" "the" "sun" [7,] "the huge" "the" "huge" [8,] "the ball" "the" "ball" [9,] "the woman" "the" "woman" [10,] "a helps" "a" "helps"
(不出所料,这种启发式名词检测的效果并不好,它还找出了一些形容词,比如 smooth 和 parked。)
如果数据是保存在 tibble 中的,那么使用 tidyr::extract() 会更容易。这个函数的工作方式与 str_match() 函数类似,只是要求为每个分组提供一个名称,以作为新列放在 tibble 中:
tibble(sentence = sentences) %>% tidyr::extract( sentence, c("article", "noun"), "(a|the) ([^ ]+)", remove = FALSE ) > tibble(sentence = sentences) %>% + tidyr::extract( + sentence, c("article", "noun"), "(a|the) ([^ ]+)", + remove = FALSE + ) # A tibble: 720 x 3 sentence article noun <chr> <chr> <chr> 1 The birch canoe slid on the smooth planks. the smooth 2 Glue the sheet to the dark blue background. the sheet 3 It's easy to tell the depth of a well. the depth 4 These days a chicken leg is a rare dish. a chicken 5 Rice is often served in round bowls. NA NA 6 The juice of lemons makes fine punch. NA NA 7 The box was thrown beside the parked truck. the parked 8 The hogs were fed chopped corn and garbage. NA NA 9 Four hours of steady work faced us. NA NA 10 Large size in stockings is hard to sell. NA NA
与 str_extract() 函数一样,如果想要找出每个字符串的所有匹配,你需要使用 str_match_ all() 函数。
9.4.4 替换匹配内容
str_replace() 和 str_replace_all() 函数可以使用新字符串替换匹配内容。最简单的应用是使用固定字符串替换匹配内容:
x <- c("apple", "pear", "banana") str_replace(x, "[aeiou]", "-") #将aeiou替换为-,只替换第一个 > [1] "-pple" "p-ar" "b-nana" str_replace_all(x, "[aeiou]", "-") #全部替换 > [1] "-ppl-" "p--r" "b-n-n-"
通过提供一个命名向量,使用 str_replace_all() 函数可以同时执行多个替换:
x <- c("1 house", "2 cars", "3 people") str_replace_all(x, c("1" = "one", "2" = "two", "3" = "three"))r > [1] "one house" "two cars" "three people"
除了使用固定字符串替换匹配内容,你还可以使用回溯引用来插入匹配中的分组。在下面的代码中,我们交换了第二个单词和第三个单词的顺序:
> head(sentences,5) [1] "The birch canoe slid on the smooth planks." [2] "Glue the sheet to the dark blue background." [3] "It's easy to tell the depth of a well." [4] "These days a chicken leg is a rare dish." [5] "Rice is often served in round bowls." sentences %>% str_replace("([^ ]+) ([^ ]+) ([^ ]+)", "\\1 \\3 \\2") %>% head(5) > sentences %>% + str_replace("([^ ]+) ([^ ]+) ([^ ]+)", "\\1 \\3 \\2") %>% + head(5) [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.
9.4.5 拆分
str_split() 函数可以将字符串拆分为多个片段。例如,我们可以将句子拆分成单词:
sentences %>% head(5) %>% str_split(" ") > sentences %>% + head(5) %>% + str_split(" ") [[1]] [1] "The" "birch" "canoe" "slid" "on" "the" "smooth" [8] "planks." [[2]] [1] "Glue" "the" "sheet" "to" "the" [6] "dark" "blue" "background." [[3]] [1] "It's" "easy" "to" "tell" "the" "depth" "of" "a" "well." [[4]] [1] "These" "days" "a" "chicken" "leg" "is" "a" [8] "rare" "dish." [[5]] [1] "Rice" "is" "often" "served" "in" "round" "bowls."
因为字符向量的每个分量会包含不同数量的片段,所以 str_split() 会返回一个列表。如 果你拆分的是长度为 1 的向量,那么只要简单地提取列表的第一个元素即可:
"a|b|c|d" %>% str_split("\\|") %>% .[[1]] > "a|b|c|d" %>% + str_split("\\|") %>% + .[[1]] [1] "a" "b" "c" "d"
否则,和返回列表的其他 stringr 函数一样,你可以通过设置 simplify = TRUE 返回一个矩阵:
sentences %>% head(5) %>% str_split(" ", simplify = TRUE) > sentences %>% + head(5) %>% + str_split(" ", simplify = TRUE) [,1] [,2] [,3] [,4] [,5] [,6] [,7] [1,] "The" "birch" "canoe" "slid" "on" "the" "smooth" [2,] "Glue" "the" "sheet" "to" "the" "dark" "blue" [3,] "It's" "easy" "to" "tell" "the" "depth" "of" [4,] "These" "days" "a" "chicken" "leg" "is" "a" [5,] "Rice" "is" "often" "served" "in" "round" "bowls." [,8] [,9] [1,] "planks." "" [2,] "background." "" [3,] "a" "well." [4,] "rare" "dish." [5,] "" ""
你还可以设定拆分片段的最大数量:
fields <- c("Name: Hadley", "Country: NZ", "Age: 35") fields %>% str_split(": ", n = 2, simplify = TRUE) > fields %>% str_split(": ", n = 2, simplify = TRUE) [,1] [,2] [1,] "Name" "Hadley" [2,] "Country" "NZ" [3,] "Age" "35"
除了模式,你还可以通过字母、行、句子和单词边界(boundary() 函数)来拆分字符串:
x <- "This is a sentence. This is another sentence." str_view_all(x, boundary("word"))
str_split(x, " ")[[1]] > str_split(x, " ")[[1]] [1] "This" "is" "a" "sentence." "This" "is" [7] "another" "sentence."
9.4.6 定位匹配内容
str_locate() 和 str_locate_all() 函数可以给出每个匹配的开始位置和结束位置。当没有其他函数能够精确地满足需求时,这两个函数特别有用。你可以使用 str_locate() 函数找出匹配的模式,然后使用 str_sub() 函数来提取或修改匹配的内容。
9.5 其他类型的模式
当使用一个字符串作为模式时,R 会自动调用 regex() 函数对其进行包装:
# 正常调用: str_view(fruit, "nana") # 上面形式是以下形式的简写 str_view(fruit, regex("nana"))
你可以使用 regex() 函数的其他参数来控制具体的匹配方式。
ignore_case = TRUE 既可以匹配大写字母,也可以匹配小写字母,它总是使用当前的区域设置:
bananas <- c("banana", "Banana", "BANANA") str_view(bananas, "banana")
str_view(bananas, regex("banana", ignore_case = TRUE))
multiline = TRUE 可以使得 ^ 和 $ 从每行的开头和末尾开始匹配,而不是从完整字符串的开头和末尾开始匹配:
x <- "Line 1\nLine 2\nLine 3" str_extract_all(x, "^Line")[[1]] > [1] "Line" str_extract_all(x, regex("^Line", multiline = TRUE))[[1]] > [1] "Line" "Line" "Line"
comments = TRUE 可以在复杂的正则表达式中加入注释和空白字符,以便更易理解。 匹配时会忽略空格和 # 后面的内容。如果想要匹配一个空格,你需要对其进行转义:"\\
": phone <- regex(" \\(? # 可选的开括号 (\\d{3}) # 地区编码 [)- ]? # 可选的闭括号、短划线或空格 (\\d{3}) # 另外3个数字 [ -]? # 可选的空格或短划线 (\\d{3}) # 另外3个数字 ", comments = TRUE) str_match("514-791-8141", phone) > str_match("514-791-8141", phone) [,1] [,2] [,3] [,4] [1,] "514-791-814" "514" "791" "814"
dotall = TRUE 可以使得 . 匹配包括 \n 在内的所有字符。
除了 regex(),还可以使用其他 3 种函数。
fixed() 函数可以按照字符串的字节形式进行精确匹配,它会忽略正则表达式中的所有特殊字符,并在非常低的层次上进行操作。这样可以让你不用进行那些复杂的转义操作, 而且速度比普通正则表达式要快很多。
install.packages("microbenchmark") microbenchmark::microbenchmark( fixed = str_detect(sentences, fixed("the")), regex = str_detect(sentences, "the"), times = 20 ) > microbenchmark::microbenchmark( + fixed = str_detect(sentences, fixed("the")), + regex = str_detect(sentences, "the"), + times = 20 + ) Unit: microseconds expr min lq mean median uq max neval fixed 93.3 95.75 129.090 98.25 105.45 616.4 20 regex 274.5 277.35 302.605 280.10 302.35 475.1 20
在匹配非英语数据时,要慎用 fixed() 函数。它可能会出现问题,因为此时同一个字符经常有多种表达方式。例如,定义 á 的方式有两种:一种是单个字母 a,另一种是 a 加 上重音符号。
coll() 函数使用标准排序规则来比较字符串,这在进行不区分大小写的匹配时是非常有效的。注意,可以在 coll() 函数中设置 locale 参数,以确定使用哪种规则来比较字符。
在介绍 str_split() 函数时,已经知道可以使用 boundary() 函数来匹配边界。还可以在其他函数中使用这个函数:
x <- "This is a sentence." str_view_all(x, boundary("word")) str_extract_all(x, boundary("word")) > str_extract_all(x, boundary("word")) [[1]] [1] "This" "is" "a" "sentence"
9.6 正则表达式的其他应用
R 基础包中有两个常用函数也可以使用正则表达式。
apropos() 函数可以在全局环境空间中搜索所有可用对象。当不能确切想起函数名称时,这个函数特别有用:
> apropos("replace") [1] "%+replace%" ".rs.registerReplaceHook" [3] ".rs.replaceBinding" ".rs.rpc.replace_comment_header" [5] "replace" "replace_na" [7] "setReplaceMethod" "str_replace" [9] "str_replace_all" "str_replace_na" [11] "theme_replace"
dir() 函数可以列出一个目录下的所有文件。dir() 函数的 patten 参数可以是一个正则表达式,此时它只返回与这个模式相匹配的文件名。例如,你可以使用以下代码返回当前目录中的所有 R Markdown 文件:
head(dir(pattern = "\\.Rmd$"))
9.7 stringi
stringr 建立于 stringi 的基础之上。stringr 非常容易学习,因为它只提供了非常少的函数, 这些函数是精挑细选的,可以完成大部分常用字符串操作功能。与 stringr 不同,stringi 的 设计思想是尽量全面,几乎包含了我们可以用到的所有函数:stringi 中有 234 个函数,而 stringr 中只有 42 个。主要区别是前缀:str_ 与 stri_。