常用生态库 intermediate 20 分钟 更新 2026-06-15

用 regex 处理文本

用 regex 做匹配、查找、捕获组与替换,从文本里提取结构化信息。

用 regex 处理文本

你会学到什么

  • Regex::new 编译一个正则模式,再去匹配文本。
  • is_match / find / find_iter:判断、查找第一个、查找全部。
  • 捕获组与命名组 (?<name>...):把匹配到的片段拆成结构化字段。
  • replace_all:把所有匹配批量替换,常用于脱敏、清洗。

regex 是 Rust 生态里处理文本的主力库。它不支持回溯,但换来了线性时间的匹配保证,正则再复杂也不会把程序拖垮。

最小示例

use regex::Regex;

let re = Regex::new(r"\d+").unwrap(); // 一个或多个数字
assert!(re.is_match("订单 42"));
assert_eq!(re.find("订单 42").unwrap().as_str(), "42");

运行代码

cd examples
cargo run -p rt_46_regex
cargo test -p rt_46_regex

依赖:

[dependencies]
regex = "1"

文件结构

本章是一个多文件示例,按职责拆成两个模块:

46_regex/
├── src/
│   ├── lib.rs       # mod 声明 + pub use 重新导出
│   ├── matching.rs  # is_match / find / find_iter
│   ├── captures.rs  # 捕获组 / 命名组 / replace_all
│   └── main.rs      # 薄壳:调用库并打印
└── README.md

lib.rs 把两个模块的常用函数 pub use 出来,main.rs 只写 use rt_46_regex::...; 就能调用。完整源码在页面下方自动嵌入,这里只讲思路。

代码讲解

匹配与查找(matching.rs)

正则用起来永远是固定三步:编译模式 → 匹配文本 → 读结果

is_emailis_match 只回答「匹不匹配」。它的模式拆开看很直观:

let re = Regex::new(r"^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$").unwrap();
re.is_match(text)

^$ 把匹配锚定到整行,否则「一句话里夹着一个邮箱」也会算通过。(?:...)非捕获组——只想分组、不想保存内容时用它,比普通 (...) 更省。

查找数字时,find 返回第一个匹配(Option<Match>),find_iter 迭代出全部:

re.find_iter(text)
    .map(|m| m.as_str().parse().unwrap())
    .collect()

m.as_str() 拿到匹配到的子串。因为 \d+ 保证是纯数字,parse 才能放心 unwrap

捕获组与命名组(captures.rs)

光知道「匹配上了」往往不够,我们还想把片段拆出来captures 返回一次匹配里的各个组:

let re = Regex::new(r"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$").unwrap();
let caps = re.captures("2026-06-15")?;
let year = caps.name("year").unwrap().as_str(); // "2026"

\d{4} 是「恰好 4 位数字」。(?<year>...) 给组起了名字,之后用 caps.name("year") 按名字取,比按序号 caps[1] 更不容易记错位置——这正是命名组的价值。

批量替换(captures.rs)

replace_all每一处匹配都换掉,还能在替换串里引用捕获组,做手机号打码再合适不过:

let re = Regex::new(r"(\d{3})\d{4}(\d{4})").unwrap();
re.replace_all(text, "${1}****${2}").into_owned()

前 3 位、后 4 位各进一个组,中间 4 位不进组、直接被换成 ****。替换串里的 ${1}${2} 引用第 1、第 2 组。写 ${1} 而不是 $1,是为了避免 $1**** 被误读成名为 1**** 的组。replace_all 返回 Cow<str>(可能借用、可能新建),into_owned 把它转成 String

常见错误

忘了用原始字符串。 正则里全是反斜杠,普通字符串会把 \d 当成转义序列:

let re = Regex::new("\d+");  // ❌ 编译报错:未知转义 \d
let re = Regex::new(r"\d+"); // ✅ 原始字符串 r"...",反斜杠原样保留

养成习惯:模式一律用 r"..." 包裹。

在循环里反复编译同一个正则。 Regex::new 要把模式解析、编译成状态机,开销不小。下面这样写,每次循环都重编译一遍:

for line in lines {
    let re = Regex::new(r"\d+").unwrap(); // ❌ 热路径里重复编译
    // ...
}

正确做法是编译一次、反复使用。真实项目里常用 std::sync::LazyLock(或 once_cell::sync::Lazy)把它放进静态变量:

use std::sync::LazyLock;
use regex::Regex;

static NUM: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").unwrap());

本章示例为了聚焦 API,在每个函数里直接 Regex::new;你在自己的代码里记得把它提到循环外。

练习

  • parse_date 加一个校验:月份超过 12 时返回 None
  • 写一个函数,用 replace_all 把文本里所有连续空白压成单个空格。
  • 用命名组解析 key=value 形式的配置行,返回 (键, 值)

小结

regex 的用法可以浓缩成一句话:编译模式、匹配文本、读出结果is_match 判断、find/find_iter 查找、captures 配合命名组提取、replace_all 批量改写——四件套覆盖了绝大多数文本处理需求。

下一步

文本之外,数据更多时候是「一串值」。下一章学 itertools,给迭代器加上分组、去重、笛卡尔积等趁手的组合子。

完整示例代码

下面是 examples/46_regex/ 的完整源码。无需 clone 仓库,直接在页面上阅读、复制、对照运行。

examples/46_regex/src/main.rs
//! 一个小演示:调用本 crate 暴露的正则功能并打印带标签的输出。
//!
//! 这些函数底层都用到了第三方包 `regex`,`main` 只管调用、不关心实现——
//! 这正是「把依赖封装进库、上层只管调用」的好处。

use rt_46_regex::{find_numbers, is_email, mask_phones, parse_date};

fn main() {
    println!("=== regex:处理文本演示 ===\n");

    println!("--- 匹配与查找 ---");
    println!(
        "alice@example.com 是邮箱吗? {}",
        is_email("alice@example.com")
    );
    println!("not-an-email 是邮箱吗?     {}", is_email("not-an-email"));
    println!("提取数字: {:?}", find_numbers("买了 3 本书,共 128 元"));

    println!();
    println!("--- 捕获组与替换 ---");
    match parse_date("2026-06-15") {
        Some(d) => println!("解析日期: {}年{}月{}日", d.year, d.month, d.day),
        None => println!("解析日期: 格式不对"),
    }
    println!("手机号打码: {}", mask_phones("联系电话 13812345678"));
}
examples/46_regex/src/lib.rs
//! 用 `regex` 这个第三方包演示「正则表达式处理文本」。
//!
//! 本 crate 把功能拆成几个模块,方便对照阅读:
//! - `matching`:最基础的匹配、查找——判断是否匹配、找出全部数字。
//! - `captures`:捕获组、命名组与批量替换——解析日期、给手机号打码。
//!
//! 所有能力都来自 crates.io 上的 `regex` 包(见 `Cargo.toml` 的
//! `[dependencies]`)。正则的核心步骤都是固定三步:用 `Regex::new` 编译模式、
//! 用 `is_match` / `find` / `captures` 之类的方法去匹配文本、再读出结果。
//!
//! 注意:编译一个 `Regex` 是相对昂贵的操作。真实项目里应当「编译一次、反复使用」,
//! 常见做法是用 `std::sync::LazyLock`(或 `once_cell::sync::Lazy`)把它放进静态变量;
//! 本例为了聚焦 API,每个函数内部直接 `Regex::new`,但教程里会专门提醒这一点。

pub mod captures;
pub mod matching;

// 重新导出常用函数,调用方可以直接 `use rt_46_regex::is_email;`。
pub use captures::{Date, mask_phones, parse_date};
pub use matching::{find_numbers, first_number, is_email};
examples/46_regex/src/captures.rs
//! 捕获组、命名组与批量替换:从文本里**提取结构化信息**、再**改写**文本。
//!
//! - `captures`:返回一次匹配里的各个捕获组。
//! - 命名组 `(?<name>...)`:用名字而不是序号读取,可读性更好。
//! - `replace_all`:把所有匹配替换成新内容,常用于脱敏、清洗。

use regex::Regex;

/// 解析出来的日期:年、月、日。
#[derive(Debug, PartialEq, Eq)]
pub struct Date {
    pub year: u16,
    pub month: u8,
    pub day: u8,
}

/// 把形如 `2026-06-15` 的字符串解析成 [`Date`]。
///
/// 模式用了**命名捕获组** `(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})`:
/// - `\d{4}` 表示「恰好 4 位数字」,`\d{2}` 是 2 位。
/// - `(?<year>...)` 给这一组起名叫 `year`,之后用 `caps.name("year")` 读取,
///   比按序号 `caps[1]` 更不容易记错位置。
///
/// `captures` 返回 `Option<Captures>`:整体匹配失败时是 `None`,于是非法格式
/// 会自然地返回 `None`。`name(...)` 返回 `Option<Match>`,因为命名组都在模式里
/// 且整体已匹配成功,这里可以安心 `unwrap`。
pub fn parse_date(text: &str) -> Option<Date> {
    let re = Regex::new(r"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$").unwrap();
    let caps = re.captures(text)?;
    Some(Date {
        year: caps.name("year").unwrap().as_str().parse().unwrap(),
        month: caps.name("month").unwrap().as_str().parse().unwrap(),
        day: caps.name("day").unwrap().as_str().parse().unwrap(),
    })
}

/// 给文本里所有「11 位手机号」打码,中间四位换成 `****`。
///
/// 关键点:
/// - 模式 `(\d{3})\d{4}(\d{4})` 用了两个普通捕获组,分别抓住前 3 位和后 4 位,
///   中间 4 位不进组、直接被替换掉。
/// - `replace_all` 把**每一处**匹配都换掉,返回 `Cow<str>`(可能借用、可能新建),
///   这里用 `into_owned` 转成 `String` 方便返回。
/// - 替换串里的 `${1}` / `${2}` 引用第 1、第 2 个捕获组。写成 `${1}` 而不是 `$1`,
///   是为了避免 `$1****` 被误解析成名为 `1****` 的组名。
pub fn mask_phones(text: &str) -> String {
    let re = Regex::new(r"(\d{3})\d{4}(\d{4})").unwrap();
    re.replace_all(text, "${1}****${2}").into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_valid_date() {
        assert_eq!(
            parse_date("2026-06-15"),
            Some(Date {
                year: 2026,
                month: 6,
                day: 15,
            })
        );
    }

    #[test]
    fn reject_bad_date() {
        assert_eq!(parse_date("2026/06/15"), None); // 分隔符不对
        assert_eq!(parse_date("26-6-15"), None); // 位数不够
        assert_eq!(parse_date("hello"), None);
    }

    #[test]
    fn mask_single_phone() {
        assert_eq!(mask_phones("联系电话 13812345678"), "联系电话 138****5678");
    }

    #[test]
    fn mask_multiple_phones() {
        assert_eq!(
            mask_phones("A:13800000000 B:13911112222"),
            "A:138****0000 B:139****2222"
        );
    }
}
examples/46_regex/src/matching.rs
//! 最基础的匹配与查找:判断是否匹配、找出文本里的所有数字。
//!
//! 这里用到 `regex` 的三个核心方法:
//! - `is_match`:只回答「匹不匹配」,返回 `bool`。
//! - `find`:找出**第一个**匹配,返回 `Option<Match>`。
//! - `find_iter`:迭代出**所有**匹配,常配合 `collect` 用。

use regex::Regex;

/// 判断一个字符串是否「看起来像」邮箱地址。
///
/// 注意:真正符合 RFC 的邮箱正则极其复杂,这里只做教学用的近似校验:
/// `用户名@域名.后缀`。模式拆开看:
/// - `[\w.+-]+`:用户名,允许字母数字、点、加号、减号。
/// - `@`:字面量 `@`。
/// - `[\w-]+`:域名主体。
/// - `(?:\.[\w-]+)+`:`(?:...)` 是**非捕获组**,`+` 表示至少一段
///   `.后缀`,于是 `a@b.c`、`a@b.co.uk` 都能通过。
/// - 首尾的 `^` 和 `$` 把匹配「锚定」到整行,避免「字符串里夹着一个邮箱」也算通过。
///
/// 模式一律用原始字符串 `r"..."` 包裹,这样 `\w`、`\.` 里的反斜杠不必再转义。
pub fn is_email(text: &str) -> bool {
    let re = Regex::new(r"^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$").unwrap();
    re.is_match(text)
}

/// 返回文本里出现的**第一个**整数(以字符串形式)。
///
/// `\d+` 匹配「一个或多个数字」。`find` 返回 `Option<Match>`,
/// 用 `map` 把 `Match` 转成 `&str` 再 `to_string`,找不到时返回 `None`。
pub fn first_number(text: &str) -> Option<String> {
    let re = Regex::new(r"\d+").unwrap();
    re.find(text).map(|m| m.as_str().to_string())
}

/// 找出文本里的**所有**整数,按出现顺序收集成 `Vec<i64>`。
///
/// `find_iter` 迭代每一个匹配;`as_str()` 拿到匹配到的子串,再 `parse` 成数字。
/// 这里能稳妥地 `unwrap`,因为 `\d+` 保证匹配到的一定是纯数字串。
pub fn find_numbers(text: &str) -> Vec<i64> {
    let re = Regex::new(r"\d+").unwrap();
    re.find_iter(text)
        .map(|m| m.as_str().parse().unwrap())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn email_accepts_valid() {
        assert!(is_email("alice@example.com"));
        assert!(is_email("bob.smith+tag@mail.co.uk"));
    }

    #[test]
    fn email_rejects_invalid() {
        assert!(!is_email("not-an-email"));
        assert!(!is_email("missing@domain")); // 没有「.后缀」
        assert!(!is_email("a @b.com")); // 含空格
    }

    #[test]
    fn first_number_works() {
        assert_eq!(
            first_number("订单 #42,金额 100 元"),
            Some("42".to_string())
        );
        assert_eq!(first_number("没有数字"), None);
    }

    #[test]
    fn find_all_numbers() {
        assert_eq!(find_numbers("买了 3 本书,共 128 元"), vec![3, 128]);
        assert_eq!(find_numbers("纯文本"), Vec::<i64>::new());
    }
}
examples/46_regex/Cargo.toml
[package]
name = "rt_46_regex"
version.workspace = true
edition.workspace = true
publish.workspace = true

[dependencies]
regex = "1"