操作文本数据
在编程和现实生活中,处理文本数据是一项非常常见的任务。例如,我们可能需要分析一段文本、在文件中查找特定字符串等。处理文本数据有时可能会变得非常具有挑战性,这就是为什么有一种特殊工具叫做 正则表达式(regular expressions),它可以让这类任务更简单、更高效。
为什么要使用正则表达式?
正则表达式(简写为 regex 或 regexp)是一串字符序列,用来描述一类字符串的通用模式。这些模式可以被用来搜索、编辑和操作文本。我们可以用它来判断整个字符串或其中某部分是否符合某个模式,或用另一个字符串替换匹配的部分。
那么我们什么时候需要这种模式呢?比如:
-
查找所有具有相同扩展名的文件(如
.pdf
); -
提取出某个名字的所有变体(例如 Edgar Poe、Edgar Allan Poe、E. A. Poe 等);
-
找出所有的电子邮箱地址;
-
或者提取所有表示日期的数字格式(如 02/03/2020)。
使用正则表达式,这些操作通常可以用一行代码完成。
正则表达式长什么样?
起初,正则表达式的语法可能看起来让人困惑,比如:\d+(\.\d)?
或 [a-zA-Z]
,而且实际使用中可能会更长。但我们会从基础讲起。
正则表达式可以看作是大多数编程语言都支持的一种“子语言”,但不同语言之间会有一些语法差异,这种差异被称为“flavor”(风格)。本章节中,我们将独立地学习正则表达式的基本概念,不依赖具体编程语言。
你也可以访问 regexp 网站,选择 PCRE(Perl Compatible Regular Expressions) 风格,来动手尝试。
示例匹配:匹配更多的 “PARK”
我们先来看一个简单的例子,来理解正则表达式是如何进行匹配的。
假设我们有一组单词:PARK
、SPARK
、PARKING
、MARK
、QUARKS
,我们想找出包含单词 PARK
的项。
我们可以使用正则表达式 PARK
,这个模式的意思是:字符 P、A、R、K 必须依次出现,且顺序不能改变。
结果如下:
-
PARK
完全匹配; -
SPARK
匹配,因为它包含了PARK
子串; -
PARKING
匹配,同样包含PARK
; -
MARK
不匹配,因为有一个字母不同(M); -
QUARKS
也不匹配,虽然它包含一些相似的字母,但不符合顺序要求。
✅ 总结:只有前三个单词匹配 PARK
模式。
⚠️ 正则表达式是区分大小写的:park
≠ PARK
。
此外,如果字符顺序错误,比如 PAKR
,也不会匹配。
正则表达式的强大之处
虽然查找子串功能不错,但正则表达式真正强大之处在于其通配符(特殊的元字符),它们允许你构造灵活的匹配模式,比如跳过某些字符、匹配多个不同字符,甚至指定重复次数。
我们先来介绍两个最基本的通配符:
点号 .
点号 .
可以匹配除换行符以外的任意单个字符。
我们回顾上面的例子,再加入一些单词。
新模式:.ARK
—— 表示“任意一个字符 + ARK”。
结果:
-
MARK
匹配(M 被.
替代); -
QUARKS
匹配(U 被.
替代); -
WARM
不匹配,因为 ARK 顺序不对。
📌 如何让 WARM
匹配?我们可以使用 .AR.
—— 表示“任意字符 + AR + 任意字符”,例如匹配 WARM
、CLARA
、PART
等。
⚠️ 注意:ARK
不匹配 .ARK
,因为它前面没有字符,而模式要求前面必须有一个任意字符。
问号 ?
问号 ?
表示前一个字符可以有,也可以没有,即“出现 0 次或 1 次”。
一个经典示例是英美拼写差异:
模式:colou?r
可以匹配 colour
(英式)或 color
(美式),但不会匹配 coloor
(多了一个 o)。
我们也可以把 ?
应用在前面的例子中:
.?ARK
表示“一个可选字符 + ARK”,这样 ARK
就可以匹配这个模式了。
✨ 你可以看到,正则表达式的强大之处在于可以组合这些通配符来构造灵活的匹配规则。
总结
正则表达式是一种可以根据模式匹配字符串的强大工具。它的基本构成包括普通字符和带有特殊含义的元字符。
以下是几个关键点:
-
.
匹配除了换行符\n
外的任意一个字符; -
?
表示前一个字符可以出现 0 次或 1 次; -
正则表达式区分大小写。
创建正则表达式
创建正则表达式
我们来看两种在 Kotlin 中创建正则表达式实例的方法。
第一种方法是先创建一个 String
实例,然后调用 toRegex()
方法,这会将字符串转换为正则表达式:
val string = "cat" // 创建字符串 "cat"
val regex = string.toRegex() // 创建正则表达式 "cat"
另一种方法是直接调用 Regex
构造函数:
val regex = Regex("cat") // 创建正则表达式 "cat"
这两种方法的效果是一样的:我们得到了所需的正则表达式。目前我们可以认为这两种方式是等价的,选择哪种方式只是个人偏好问题。
简单匹配
现在我们来看看如何实际使用正则表达式。我们要介绍的第一个方法是 matches()
,它用于完全匹配,也就是说,整个字符串必须与模式相匹配。
函数签名如下:
fun Regex.matches(input: CharSequence): Boolean
它接收一个正则表达式,并将其与输入字符串进行匹配,返回布尔结果。
下面是一些示例代码:
val regex = Regex("cat") // 创建正则表达式 "cat"
val stringCat = "cat"
val stringDog = "dog"
val stringCats = "cats"
println(regex.matches(stringCat)) // true
println(regex.matches(stringDog)) // false
println(regex.matches(stringCats)) // false
正如上面的例子所示,当我们的正则表达式只是一个简单的字符串时,只有完全相同的字符串才会返回 true
。
这看起来似乎有些多余:毕竟我们可以简单地比较两个字符串,这样更快也更容易。确实如此,但请记住,正则表达式真正的强大之处在于它允许使用特殊字符来定义模式,这些模式可以同时匹配多个不同的字符串。
除了 Regex.matches()
,还有两个函数可以检查正则表达式是否与字符串中特定位置的部分内容匹配:
Regex.matchAt()
该函数如果找到了匹配项,则返回 MatchResult
类型的结果对象,否则返回 null
。我们可以通过 value
属性访问匹配到的字符串。
val regex = Regex("cat")
val stringCat = "The cat is eating."
println(regex.matchAt(stringCat, 4)?.value) // cat
println(regex.matchAt(stringCat, 5)?.value) // null
Regex.matchesAt()
该函数检查是否存在匹配项,并返回布尔值:
val regex = Regex("cat")
val stringCat = "The cat is eating."
println(regex.matchesAt(stringCat, 4)) // true
println(regex.matchesAt(stringCat, 5)) // false
点号 .
字符
这个特殊字符你应该已经熟悉了:点号 .
可以匹配除了换行符 \n
以外的任意单个字符。它可以匹配字母、数字、空格等等。其他控制字符,如 \b
和 \t
也可以匹配。
以下是一些使用点号的例子:
val regex = Regex("cat.") // 创建 "cat." 正则表达式
val stringCat = "cat."
val stringEmotionalCat = "cat!"
val stringCatWithSpace = "cat "
val stringCatN = "cat\n"
println(regex.matches(stringCat)) // true
println(regex.matches(stringEmotionalCat)) // true
println(regex.matches(stringCatWithSpace)) // true
println(regex.matches(stringCatN)) // false
这并不复杂,但非常有用。例如,当你需要查找某个词在不同形式下出现在文本中时,点号就能派上用场。
问号 ?
字符
问号 ?
是一个特殊字符,表示“前一个字符可有可无”。
下面的例子演示了它的用法:
val regex = Regex("cats?") // 创建 "cats?" 正则表达式
val stringCat = "cat"
val stringManyCats = "cats"
println(regex.matches(stringCat)) // true
println(regex.matches(stringManyCats)) // true
你还可以将点号 .
与问号 ?
组合在一起使用。这种组合的意思是“可以有一个任意字符,也可以没有”:
val regex = Regex("cat.?") // 创建 "cat.?" 正则表达式
val stringCat = "cat"
val stringManyCats = "cats"
val stringEmotionalCat = "cat!"
val stringCot = "cot"
println(regex.matches(stringCat)) // true
println(regex.matches(stringManyCats)) // true
println(regex.matches(stringEmotionalCat)) // true
println(regex.matches(stringCot)) // false
这能让你的操作更简单。但等一下:如果你需要匹配的就是一个实际的点号或问号,该怎么办呢?
转义字符
当你在正则表达式中需要匹配一个原本具有特殊意义的字符时,可以使用转义字符来实现。在 Kotlin 中,我们可以使用双反斜杠 \\
对特殊字符进行转义:
val regex = Regex("cats\\?")
val stringCat = "cat"
val stringManyCats = "cats"
val stringEmotionalCats = "cats?"
println(regex.matches(stringCat)) // false
println(regex.matches(stringManyCats)) // false
println(regex.matches(stringEmotionalCats)) // true
在上面的例子中,问号被解释为了普通字符“?”,不再具有特殊含义。
因此,如果你需要匹配诸如点号 .
或问号 ?
这样的特殊字符,只需使用 \\
来转义它们即可。经过转义的字符将不会被当作正则表达式的特殊符号处理。
总结
正则表达式是在 Kotlin 中处理字符串的强大工具。现在你已经学会了如何创建 Regex
实例,并使用 matches()
函数进行完整匹配。
记住以下几个特殊字符的含义:
-
点号
.
可以匹配任意单个字符; -
问号
?
表示“前一个字符可有可无”; -
双反斜杠
\\
是转义符,用来把特殊字符当作普通字符使用。
量词
在正则表达式中,有一组称为**量词(quantifiers)**的字符,它们用于定义某个字符(或字符类)在模式中出现的次数。量词既可以跟在普通字符后,也可以跟在特殊字符后。一般来说,量词是正则表达式语言中最基本、最重要的特性之一,因为它们可以让一个模式匹配多种长度不同的字符串。
量词列表
以下是需要记住的量词列表:
-
+
:匹配一个或多个前面的字符; -
*
:匹配零个或多个前面的字符; -
{n}
:匹配恰好 n 个前面的字符; -
{n,m}
:匹配至少 n 个但不超过(可以等于) m 个前面的字符; -
{n,}
:匹配至少 n 个前面的字符; -
{0,m}
:匹配最多 m 个前面的字符。
注意,还有一个量词 ?
,它表示前面的字符是可选的,相当于 {0,1}
。这里我们不再详细介绍 ?
,因为你应该已经熟悉它了。
加号量词 +
下面的例子展示了加号 +
的用法,它会匹配一个或多个前一个字符的出现:
val regex = "ca+b".toRegex()
regex.matches("cab") // true
regex.matches("caaaaab") // true
regex.matches("cb") // false,因为没有至少一个 'a'
解释: 如你所见,+
只匹配那些包含一个或多个 'a'
的字符串。
星号量词 *
以下示例展示了星号 *
的用法,它会匹配零个或多个前一个字符的出现:
val regex = "A[0-3]*".toRegex()
regex.matches("A") // true,因为可以匹配零次出现
regex.matches("A0") // true
regex.matches("A000111222333") // true
解释: 与加号不同,星号允许模式匹配那些完全不包含被量词修饰字符的字符串。
在下一个例子中,我们使用一个模式来描述字符串 "John"
出现在文本中的任意位置:
val johnRegex = ".*John.*".toRegex() // 匹配所有包含子字符串 "John" 的字符串
val textWithJohn = "My friend John is a computer programmer"
johnRegex.matches(textWithJohn) // true
val john = "John"
johnRegex.matches(john) // true
val textWithoutJohn = "My friend is a computer programmer"
johnRegex.matches(textWithoutJohn) // false
解释: 星号量词可以用于判断一个字符串是否包含某个子字符串。使用它可以忽略空格或其他不想在模式中精确指定的字符。
指定重复次数的量词
前面提到的量词用途广泛,但它们不支持精确指定字符重复的次数。好在,我们可以用花括号 {}
中的数字来控制字符出现的次数,例如 {n}
、{n,m}
和 {n,}
。
⚠️ 重要说明:花括号中不能包含空格,只能包含一个或两个数字以及一个可选的逗号。加空格会导致量词失效,从而变成一个完全不同的正则表达式。
来看一个使用 {n}
量词来精确匹配 n 次字符的例子:
val regex = "[0-9]{4}".toRegex() // 匹配四个数字
regex.matches("6342") // true
regex.matches("9034") // true
regex.matches("182") // false
regex.matches("54312") // false
**解释:**只匹配长度恰好为 4 的数字字符串。
接下来是使用 {n,m}
量词匹配从 n 到 m 次字符出现的例子(注意:这个范围是包含两端的,即 n 和 m 都算):
val regex = "1{2,3}".toRegex()
regex.matches("1") // false
regex.matches("11") // true
regex.matches("111") // true
regex.matches("1111") // false
解释: 匹配 1 出现 2 或 3 次的字符串。
最后是使用 {n,}
量词匹配至少 n 次字符出现的例子:
val regex = "ab{4,}".toRegex()
regex.matches("abb") // false,不够 4 个 b
regex.matches("abbbb") // true
regex.matches("abbbbbbb") // true
解释:b
至少出现 4 次才匹配。
你还可以尝试 {0,m}
形式的量词,它的作用是匹配最多 m 次的字符。
总结
本节的关键点如下:
-
在正则表达式语言中,量词可以让我们匹配不同长度的字符串;
-
*
(星号)匹配前一个字符零次或多次; -
+
(加号)几乎与*
相同,但要求至少出现一次; -
使用花括号
{}
可以更精确控制字符出现的次数,比如指定最少、最多或精确的数量。
字符集简写
在正则表达式中,有一些字符集被频繁使用,比如数字字符集、字母数字字符集,以及空白字符集(实际上空白字符种类很多)。为了更方便和快速地使用这些常用字符集,正则表达式提供了一些特殊的简写形式,本节我们将详细讲解这些简写。
简写字符列表
以下是一些针对常用字符集的预定义简写:
-
\d
表示任意数字,相当于[0-9]
; -
\s
表示任意空白字符(包括空格、制表符、换行等),相当于[ \t\n\x0B\f\r]
; -
\w
表示字母数字字符(包括字母、数字和下划线),相当于[a-zA-Z_0-9]
; -
\b
表示单词边界。这个有点特殊:它并不匹配任何具体字符,而是匹配字母数字字符或下划线与非字母数字字符之间的边界,或者匹配字符串的开始/结束边界。-
例如:
"\ba"
匹配所有以 “a” 开头的单词; -
"a\b"
匹配所有以 “a” 结尾的单词; -
"\ba\b"
匹配被非字母数字字符包围的单个 “a”。
-
此外,还有这些简写的反义版本,它们匹配不属于上述集合的字符:
-
\D
表示非数字,相当于[^0-9]
; -
\S
表示非空白字符,相当于[^ \t\n\x0B\f\r]
; -
\W
表示非字母数字字符,相当于[^a-zA-Z_0-9]
; -
\B
表示非单词边界。它匹配与\b
相反的情况:也就是两个字母数字字符之间的“无缝连接”位置。- 例如:
"a\B"
匹配所有以 a 开头但后面紧跟其他字母数字字符的单词。
- 例如:
这些简写使得编写正则表达式变得更加简洁和直观。
每个简写的含义都可以通过它的首字母来记住(digit, space, word, boundary)。当使用大写字母时,就表示其反义简写。
示例
我们来看几个 Kotlin 中使用这些简写的例子。注意,在 Kotlin 中,由于字符串中 \
是转义字符,我们要使用双反斜杠 \\
:
val regex = "\\s\\w\\d\\s".toRegex()
regex.matches(" A5 ") // true
regex.matches(" 33 ") // true
regex.matches("\tA4\t") // true,因为制表符也是空白字符
regex.matches("q18q") // false,第一个字符不是空白字符
regex.matches(" AB ") // false,B 不是数字
regex.matches(" -1 ") // false,'-' 不是字母数字字符,尽管 '1' 是数字
还有一种方式是使用 Kotlin 的 原始字符串(使用三个双引号包裹),这样就不需要转义反斜杠:
val regex = """\W\S\D\S\W""".toRegex()
regex.matches(" 9o9 ") // true
regex.matches("\nA 1 ") // true
regex.matches("\tAl4\t") // true
regex.matches(" \taa ") // false,'\t' 是空白字符
regex.matches("_BBB ") // false,'_' 是字母数字字符
单词边界的示例:
val startRegex = "\\bcat".toRegex() // 匹配以 "cat" 开头的单词部分
val endRegex = "cat\\b".toRegex() // 匹配以 "cat" 结尾的单词部分
val wholeRegex = "\\bcat\\b".toRegex() // 完整匹配单词 "cat"
不使用简写时的替代写法:
val regex = "[ \\t\\n\\x0B\\f\\r][a-zA-Z_0-9][0-9][ \\t\\n\\x0B\\f\\r]".toRegex()
这段代码虽然可以实现同样的功能,但长度冗长、可读性差,还重复了很多字符。相比之下,使用简写形式更为清晰易读。
总结
在本节中,介绍了以下内容:
-
正则表达式中有一些简写符号,代表最常用的字符集;
-
这些简写使用双反斜杠
\\
和小写字母表示; -
它们还有反义版本,使用大写字母表示,表示匹配不属于某类的字符;
-
简写的使用可以大幅简化正则表达式的书写和阅读。