Rust 正则表达式
正则表达式(regular expression,简称 regex)是一种描述文本的迷你模式语言。你不必说“找到单词 cat”,而可以说“找到四个数字、一个连字符、两个数字、一个连字符、两个数字”,一下子匹配出所有像 2024-05-17 这样的日期。
Rust 标准库的字符串有不少好用的方法,比如 contains、split 和 find(见字符串)。当固定的一段文本不够用,你需要的是文本的某种形状时,就用 regex(英文)crate。它由 Rust 项目维护,速度非常快。
添加 regex,完成第一次匹配
Section titled “添加 regex,完成第一次匹配”在项目文件夹里运行:
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("去年春天发布。"));}输出:
truefalse\d 表示“任意一个数字”,{4} 表示“前面那一项正好出现四次”。is_match 只回答一个问题:文本里任何地方有没有匹配?
注意模式前面的 r:r"\d{4}"。这是一个原始字符串(raw string)。在普通的 Rust 字符串里,\ 会开始一个转义,比如 \n(换行)。正则表达式里到处都是反斜杠,用原始字符串就不必写成 "\\d{4}"。写模式时请一律使用原始字符串。
为什么 Regex::new 返回 Result
Section titled “为什么 Regex::new 返回 Result”模式本身就是一个小程序,它也可能写错,比如忘了闭合括号。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 和 find_iter
Section titled “查找匹配:find 和 find_iter”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 个字节,换成中文这些数字就变了。
捕获组和命名组
Section titled “捕获组和命名组”很多时候你要的不只是整个匹配,而是其中的几个部分。把模式的一部分用圆括号括起来,就能捕获(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日:1717/05/202402/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) 切出两段就停下。
只编译一次,别放进循环
Section titled “只编译一次,别放进循环”构建 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() { println!("{input:22} -> {}", is_email(input)); }}输出:
[email protected] -> true[email protected] -> truenot-an-email -> falsex@y -> false这个模式里的新内容:
(?i)开启不区分大小写的匹配,所以[a-z]也能匹配B和M。^和$分别表示“文本开头”和“文本结尾”。没有它们的话,is_match对"hello [email protected]!!"也会回答“是”,因为邮箱地址出现在了其中某个地方。\.是一个真正的点。单独的.表示“任意字符”。
这只是一个“看起来像邮箱”的检查,适合用来发现输入错误。想用正则表达式真正验证每一个合法的邮箱地址,难度是出了名的,唯一靠谱的测试是真的给它发一封邮件。
示例:从日志行里提取数据
Section titled “示例:从日志行里提取数据”把学到的内容组合起来。我们读取一段日志,跳过格式不对的行,对每一行 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 in2024-05-17 09:15:44 ERROR payment failed for [email protected]this line is garbage2024-05-18 11:02:10 WARN disk 91% full
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对齐。- 在真实的程序里,你会从文件中逐行读取日志。见文件读写。
regex 不做的事(有意为之)
Section titled “regex 不做的事(有意为之)”如果你在 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}。- 模式只编译一次,然后反复使用,比如放进一个
staticLazyLock。 - 不支持环视和反向引用,这是设计上的选择:换来的是匹配时间永远是线性的。
小测验
1. 为什么 Rust 里的正则模式通常以 r 开头,比如 r"\d+"?
2. 使用模式 (?<y>\d{4})-(?<m>\d{2}),对文本 2024-05 来说,caps["m"] 得到什么?
3. 为什么 regex crate 不支持环视和反向引用?
4. 一个模式要用在大文件的每一行上,Regex::new 最好放在哪里调用?
得分: 0 / 4
regexcrate 文档 — docs.rs(英文):官方指南,有大量示例和完整的语法参考。RegexAPI — docs.rs(英文):所有方法,包括captures_iter、replacen和split。LazyLock— 标准库文档(英文):延迟初始化的 static 是如何工作的。- Regular Expressions — Rust Cookbook(英文):提取话题标签、电话号码、邮箱地址等的简短示例。