跳转到内容

Rust 正则表达式

正则表达式(regular expression,简称 regex)是一种描述文本的迷你模式语言。你不必说“找到单词 cat”,而可以说“找到四个数字、一个连字符、两个数字、一个连字符、两个数字”,一下子匹配出所有像 2024-05-17 这样的日期。

Rust 标准库的字符串有不少好用的方法,比如 contains、split 和 find(见字符串)。当固定的一段文本不够用,你需要的是文本的某种形状时,就用 regex(英文)crate。它由 Rust 项目维护,速度非常快。

在项目文件夹里运行:

Terminal window
cargo add regex

它会在 Cargo.toml 的 [dependencies] 下加上 regex = "1"。(还不熟悉 crate?见 Cargo 和 crate。)“Try it”按钮会打开 Rust Playground,那里已经自带了 regex。

use regex::Regex;
fn main() {
let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("于 2024-05-17 发布。"));
println!("{}", re.is_match("去年春天发布。"));
}

输出:

true
false
▶ 动手试一试

\d 表示“任意一个数字”,{4} 表示“前面那一项正好出现四次”。is_match 只回答一个问题:文本里任何地方有没有匹配?

注意模式前面的 r:r"\d{4}"。这是一个原始字符串(raw string)。在普通的 Rust 字符串里,\ 会开始一个转义,比如 \n(换行)。正则表达式里到处都是反斜杠,用原始字符串就不必写成 "\\d{4}"。写模式时请一律使用原始字符串。

模式本身就是一个小程序,它也可能写错,比如忘了闭合括号。Regex::new 会检查模式,并返回一个 Result:模式有效就是 Ok(Regex),无效则是带着有用提示的 Err:

use regex::Regex;
fn main() {
match Regex::new(r"(\d+") {
Ok(_) => println!("模式有效"),
Err(e) => println!("模式有误:\n{e}"),
}
}

输出:

模式有误:
regex parse error:
(\d+
^
error: unclosed group
▶ 动手试一试

错误信息由 regex crate 输出,所以是英文:unclosed group 的意思是“括号没有闭合”,^ 指出了出问题的位置。

如果模式写在你的代码里,而且你确定它没错,用 .unwrap() 就可以:万一写错了,程序第一次运行时你就会发现。如果模式来自用户(比如搜索框),就要处理 Err,别让程序崩溃。

find 以 Option 的形式返回第一个匹配。find_iter 则给你一个遍历所有匹配的迭代器:

use regex::Regex;
fn main() {
let re = Regex::new(r"\d+").unwrap();
let text = "3 apples, 12 pears and 7 plums";
if let Some(m) = re.find(text) {
println!("第一个数字:{}(位于字节 {}..{})", m.as_str(), m.start(), m.end());
}
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect();
println!("所有数字:{:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum();
println!("水果总数:{total}");
}

输出:

第一个数字:3(位于字节 0..1)
所有数字:["3", "12", "7"]
水果总数:22
▶ 动手试一试

\d+ 表示“一个或多个数字”,所以 12 会作为一个匹配被找到,而不是两个。每个匹配都知道自己的文本(as_str())和位置(start() 和 end(),以字节为单位)。正因为位置按字节计算,text 保留了英文:一个汉字在 UTF-8 里占 3 个字节,换成中文这些数字就变了。

很多时候你要的不只是整个匹配,而是其中的几个部分。把模式的一部分用圆括号括起来,就能捕获(capture)它。再用 (?<name>...) 给它起个名字,代码会更好读:

use regex::Regex;
fn main() {
let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap();
let text = "备份在 2024-05-17 和 2024-06-02 运行过。";
if let Some(caps) = re.captures(text) {
println!("完整匹配:{}", &caps[0]);
println!("年:{}", &caps["year"]);
println!("月:{}", &caps[2]);
println!("日:{}", caps.name("day").unwrap().as_str());
}
for caps in re.captures_iter(text) {
println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]);
}
}

输出:

完整匹配:2024-05-17
年:2024
月:05
日:17
17/05/2024
02/06/2024
▶ 动手试一试

读取一个组有三种方法:

  • caps[0] 永远是整个匹配。组从 1 开始、从左到右编号:caps[2] 就是月份。
  • caps["year"] 按名字读取组。如果这个组没有参与匹配,用 [...] 索引会 panic。
  • caps.name("day") 返回一个 Option,组是可选的时候,这是安全的写法。

captures 找到第一个匹配。captures_iter 会遍历每一个匹配,和 find_iter 一样。

replace 替换第一个匹配,replace_all 替换每一个匹配。在替换文本里,$name(或 $1)会插入某个组捕获到的内容:

use regex::Regex;
fn main() {
let spaces = Regex::new(r"\s+").unwrap();
println!("{}", spaces.replace_all("too many spaces", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap();
let text = "从 2024-05-17 到 2024-06-02";
println!("{}", date.replace(text, "$d/$m/$y"));
println!("{}", date.replace_all(text, "$d/$m/$y"));
println!("{}", date.replace_all(text, "$m_$y"));
println!("{}", date.replace_all(text, "${m}_${y}"));
}

输出:

too many spaces
从 17/05/2024 到 2024-06-02
从 17/05/2024 到 02/06/2024
从 2024 到 2024
从 05_2024 到 06_2024
▶ 动手试一试

看第四行。它打印的是 从 2024 到 2024,而不是 05_2024。为什么?组名里可以有 _,所以 $m_ 被当成了一个叫 m_ 的组。没有这个组,于是它变成了空文本。把名字放进花括号,写成 ${m}_,就能明确指出名字在哪里结束。拿不准的时候,就用花括号。

\s+ 表示“一个或多个空白字符”,把它替换成一个空格,是整理杂乱输入的经典做法。

split 在每个匹配模式的地方把字符串切开。它和 str::split 很像,只不过分隔符可以是一个模式:

use regex::Regex;
fn main() {
let sep = Regex::new(r"\s*[,;]\s*").unwrap();
let parts: Vec<&str> = sep.split("apple, banana;cherry , date").collect();
println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect();
println!("{:?}", two);
}

输出:

["apple", "banana", "cherry", "date"]
["a", "b, c, d"]
▶ 动手试一试

[,;] 表示“一个逗号或一个分号”,两边的 \s* 会吞掉它周围的空格。splitn(text, 2) 切出两段就停下。

构建 Regex 才是慢的部分:crate 要把你的模式变成一个高效的匹配器。而使用它很快。所以千万别在循环里,或者在频繁运行的函数里调用 Regex::new。构建一次,然后反复使用。

如果一个模式在程序各处都会用到,就用 std::sync::LazyLock 把它放进一个 static。它会在第一次使用时构建,之后共享:

use regex::Regex;
use std::sync::LazyLock;
static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$").unwrap()
});
fn is_email(s: &str) -> bool {
EMAIL.is_match(s)
}
fn main() {
for input in ["[email protected]", "[email protected]", "not-an-email", "x@y"] {
println!("{input:22} -> {}", is_email(input));
}
}

输出:

not-an-email -> false
x@y -> false
▶ 动手试一试

这个模式里的新内容:

  • (?i) 开启不区分大小写的匹配,所以 [a-z] 也能匹配 B 和 M。
  • ^ 和 $ 分别表示“文本开头”和“文本结尾”。没有它们的话,is_match 对 "hello [email protected]!!" 也会回答“是”,因为邮箱地址出现在了其中某个地方。
  • \. 是一个真正的点。单独的 . 表示“任意字符”。

这只是一个“看起来像邮箱”的检查,适合用来发现输入错误。想用正则表达式真正验证每一个合法的邮箱地址,难度是出了名的,唯一靠谱的测试是真的给它发一封邮件。

把学到的内容组合起来。我们读取一段日志,跳过格式不对的行,对每一行 ERROR 打印日期、时间以及消息里的所有邮箱地址:

use regex::Regex;
use std::sync::LazyLock;
static LOG_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +(?<msg>.*)$").unwrap()
});
static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap()
});
fn main() {
let log = "\
2024-05-17 09:12:03 INFO user [email protected] logged in
2024-05-17 09:15:44 ERROR payment failed for [email protected]
this line is garbage
2024-05-18 11:02:10 WARN disk 91% full
2024-05-18 11:05:00 ERROR could not email [email protected] or [email protected]";
let mut errors = 0;
for line in log.lines() {
let Some(caps) = LOG_LINE.captures(line) else {
println!("已跳过:{line:?}");
continue;
};
if &caps["level"] == "ERROR" {
errors += 1;
let emails: Vec<&str> = EMAIL.find_iter(&caps["msg"]).map(|m| m.as_str()).collect();
println!("{} {} -> {:?}", &caps["date"], &caps["time"], emails);
}
}
println!("共发现 {errors} 个错误");
}

输出:

2024-05-17 09:15:44 -> ["[email protected]"]
已跳过:"this line is garbage"
2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]
共发现 2 个错误
▶ 动手试一试

几点说明:

  • log 开头的 "\ 是一个 Rust 小技巧:字符串里行尾的反斜杠会跳过换行符,所以第一行日志紧接着就开始了。
  • let Some(caps) = ... else { ... } 是 let-else:如果这一行不匹配,就打印它,然后 continue 到下一行。
  • (?<level>[A-Z]+) + 先匹配日志级别,再匹配一个或多个空格,因为 INFO 和 WARN 后面多补了一个空格,好和 ERROR 对齐。
  • 在真实的程序里,你会从文件中逐行读取日志。见文件读写。

如果你在 JavaScript、Python 或 Perl 里用过正则表达式,可能会想用环视(look-around,(?=...)、(?<=...))或者反向引用(backreference,\1,即“和第 1 组相同的文本”)。regex crate 不支持它们,而且会明确告诉你:

regex parse error:
foo(?=bar)
^^^
error: look-around, including look-ahead and look-behind, is not supported

意思是:不支持环视,包括向前环视和向后环视。

这是一个刻意的取舍。这些特性会让某些模式在特定输入上花费指数级的时间,一个很短的模式加上一个足够长的字符串,就能让程序卡死(这叫作灾难性回溯,catastrophic backtracking,它真的搞垮过一些网站)。去掉这些特性之后,regex crate 就能保证匹配时间只与文本长度成正比增长(再乘以模式的大小)。文本长一倍,工作量大约也只多一倍,永远不会爆炸。所以即使模式或文本来自用户,它也是安全的。

通常换个办法也能得到同样的结果:用一个捕获组,只读取你想要的那一部分;或者用普通的 Rust 代码再做一次简单的检查。如果你确实需要这些特性,fancy-regex(英文)crate 提供了它们,但没有时间上的保证。

模式 匹配
. 除换行符以外的任意字符
\d \w \s 一个数字、一个“单词”字符(字母、数字、_)、空白字符
\D \W \S 反过来:非数字、非单词字符、非空白字符
[abc] [a-z] [^0-9] a、b、c 之一;任意小写字母;数字以外的任意字符
* + ? 前一项出现零次或多次、一次或多次、零次或一次
{3} {2,5} {2,} 正好 3 次、2 到 5 次、至少 2 次
^ $ 文本的开头和结尾
\b 单词边界(单词字符和非单词字符之间)
(...) (?<name>...) 编号组、命名组
(?i) 从这里开始不区分大小写

还有一个:竖线表示“或”。例如,cat|dog 匹配这两个单词中的任意一个,gr(a|e)y 匹配 gray 和 grey。要按字面匹配特殊字符,就在前面加反斜杠:\.、\(、\$。

忘了用原始字符串。在普通字符串里,\d 不是有效的转义,所以 regex crate 还没看到你的模式,Rust 就先报错了:

use regex::Regex;
fn main() {
let re = Regex::new("\d+").unwrap();
println!("{}", re.is_match("id=22"));
}
error: unknown character escape: `d`
--> src/main.rs:4:27
|
4 | let re = Regex::new("\d+").unwrap();
| ^ unknown character escape
|
help: if you meant to write a literal backslash (perhaps escaping in a regular expression), consider a raw string literal
|
4 | let re = Regex::new(r"\d+").unwrap();
| +

错误信息说 \d 是未知的转义字符。编译器甚至给出了修复方法:在前面加上 r,改用原始字符串。

在循环里构建正则表达式。这样能编译,也能运行,但很慢,因为每一行都要重新编译一次模式:

for line in lines {
let re = Regex::new(r"\d+").unwrap(); // 一遍又一遍地重新编译
if re.is_match(line) { /* ... */ }
}

把 Regex::new 移到循环上面,或者像前面那样放进一个 LazyLock static。

做验证时忘了 ^ 和 $。is_match 会在任何地方寻找模式。要检查整个输入是否符合某种形状,就要在模式的两端加上锚点。

  • 用 cargo add regex 添加这个 crate,并用原始字符串写模式:r"\d+"。
  • Regex::new 返回 Result,因为模式可能无效。
  • is_match 回答是或否;find/find_iter 给出匹配到的文本;captures/captures_iter 按编号或按 (?<name>...) 给出各个组。
  • replace 和 replace_all 可以用 $name 插入组的内容;后面紧跟字母或 _ 时,用 ${name}。
  • 模式只编译一次,然后反复使用,比如放进一个 static LazyLock。
  • 不支持环视和反向引用,这是设计上的选择:换来的是匹配时间永远是线性的。

小测验

1. 为什么 Rust 里的正则模式通常以 r 开头,比如 r"\d+"?

2. 使用模式 (?<y>\d{4})-(?<m>\d{2}),对文本 2024-05 来说,caps["m"] 得到什么?

3. 为什么 regex crate 不支持环视和反向引用?

4. 一个模式要用在大文件的每一行上,Regex::new 最好放在哪里调用?

得分: 0 / 4