回上级页面

正则表达式

2023 年 06 月 19 日


问题

上一章给出的文本分割方案仅适用于一种情况,分割符以确定的形式出现。对于以下文本

num@ 123@456  @ 789

@ 前后可能存在 1 个或更多个空白字符,若以该形式的分割符对文本进行分割,则分割符便存在着不确定性,该如何表达这种分割符并将其用于文本分割呢?

正则表达式

2023 年了,如果一个为计算机编写程序的人不知正则表达式为何物,他的名字可能叫南郭先生。

正则表达式本质上是一种微型语言,例如字符串形式的正则表达式 " *@ *" 可表达形式为「前后可能存在 1 个或更多个空白字符的 @ 」的字符串。在正则表达式语法中,* 表示位于它之前的字符可以不存在,或出现 1 此,或重复无数次,故而当 * 之前是一个空白字符时,便可表示该空白字符可能存在 1 个或更多个。

有很多编程语言本身或其标准库提供了而对正则表达式的支持,诸如 AWK,Perl,Python 等语言。Rust 编译器以及标准库未提供正则表达式的解析功能,故而无法直接使用正则表达式实现文本匹配和分割等功能。对于大多数程序员而言,自行编写正则表达式解析器是一项艰难的任务,可参考「如何从零写一个正则表达式引擎?」。不过,大可不必为此焦虑,Rust 有一些第三方库提供了正则表达式支持,其中应用最为广泛的是 Regex。

cargo

使用第三方库,就很难继续维持徒手使用 rustc 构建程序的田园时光了,需要使用 Rust 官方提供的项目构建工具 cargo。下面使用 cargo 逐步构建一个可使用正则表达式作为分割符对文本进行分割的项目,假设该项目名为 regex-split。

首先,使用 cargo 创建 regex-split 项目,并查看项目的目录结构:

$ cargo new regex-split
$ cd regex-split
$ tree .
.
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

其中 Cargo.toml 内容为

[package]
name = "regex-split"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

现在虽然是 2023 年了,但是由于我的 cargo 工具的版本还是 2021,故而 Cargo.toml 中的 edition 的值切不可擅自修改为 2023,否则接下来执行 cargo 命令时,会得到以下错误信息:

error: failed to parse manifest at `/tmp/regex-split/Cargo.toml`

Caused by:
  failed to parse the `edition` key

使用以下命令为 regex-split 增加对版本号为 “1” 的 regex 库的依赖:

$ cargo add regex@1

执行上述命令,cargo 会在 Cargo.toml 中的 [dependencies] 部分增加以下内容:

regex = "1"

此处需要一个小插曲。上述 cargo 命令需要从 https://crates.io 获取该网站上的 Rust 第三方库库文件信息。由于众所周知的原因,国内大陆地区对 https://crates.io 的访问速度尤为缓慢,若想提速,需要使用该网站在国内大陆的镜像。对于 Linux 用户,只需在 $HOME/.cargo 目录创建(或修改)config 文件,令其内容为

[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = 'ustc'
[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"

便可使用中国科技大学的镜像站点代替 https://crates.io,使得 regex-split 项目得以引入 regex 库,否则只能望洋兴叹。

若上述 cargo 命令皆执行无误,现在便可编辑 regex-split 项目的 src 子目录下的 main.rs 文件,令其内容为

// 从 regex 库导入 Regex 类型
use regex::Regex;

fn main() {
    // 构造正则表达式
    let re = Regex::new(" *@ *").unwrap();
    // 调用正则表达式的字符串分割方法
    let v = re.split("num@ 123@456  @ 789");
    for i in v {
        println!("{}", i);
    }
}

然后在 regex-split 项目根目录执行以下命令:

$ cargo run

该命令的输出信息为

  Downloaded aho-corasick v1.0.2 (registry `ustc`)
  Downloaded regex-syntax v0.7.2 (registry `ustc`)
  Downloaded regex v1.8.4 (registry `ustc`)
  Downloaded 3 crates (757.0 KB) in 2.46s
   Compiling memchr v2.5.0
   Compiling regex-syntax v0.7.2
   Compiling aho-corasick v1.0.2
   Compiling regex v1.8.4
   Compiling regex-split v0.1.0 (/tmp/regex-split)
    Finished dev [unoptimized + debuginfo] target(s) in 15.18s
     Running `target/debug/regex-split`
num
123
456
789

基于上述输出信息可知,上述的 cargo run 命令完成了以下四件事情:

unwrap?

我在第一次学习 Rust 语言时,从一些教程中看到 unwrap 方法时,觉得它像噪音。例如上一节的代码里有

let re = Regex::new(" *@ *").unwrap();

即便后来我明白了这是 Rust 语言的好意,但依然觉得很不舒服,因为它破坏了语义,亦即为什么我创建了一个正则表达式对象,还要对它进行解封装?

随着对 Rust 语言有了更多的了解,就会明白 Rust 的标准库以及大多数第三方库为了程序的安全性强迫程序员去检测函数的返回值是否正确,故而许多函数将其计算结果封装为 Result 类型(枚举类型)的值并返回。上述代码实际上等同于

let re = match Regex::new(" *@ *") {
    Ok(v) => v,
    Err(e) => panic!("Error: {}", e)
};

使用 Rust 的模式匹配语法可对 Result 或其他复合类型的值进行解析。上述代码的含义是,如果 Regex::new 返回的是 Ok(v) 形式的值,则从该值中提取 v,而如果 Regex::new 返回的是 Err(e) 形式的值,则通过 Rust 宏 panic! 让程序直接报错并终止。当然,倘若你认为一个程序出现了错误,但未必要判它死刑,可在 Err(e) => ... 分支中给出你觉得合理的判决。

还有一种推卸责任的办法,将 Result 类型的值返回,让调用者负责处理错误情况。例如

// 从 regex 库导入 Regex 和 Error 类型
use regex::{Regex, Error};

fn main() -> Result<(), Error> {
    // 构造正则表达式
    let re = match Regex::new(" *@ *") {
        Ok(v) => v,
        Err(e) => return Err(e)
    };
    // 调用正则表达式的字符串分割方法
    let v = re.split("num@ 123@456  @ 789");
    for i in v {
        println!("{}", i);
    }
    return Ok(());
}

在 Rust 语言中,() 类型表达的是不包含任何元素的元组,若将其作为函数的返回值,可用于表示该函数没有返回值。没有返回值的函数或者以分号结尾的表达式,都返回 () 类型。上述代码将 () 类型的值封装为一个 Result 类型的值,并将其作为 main 函数的返回值。在上述代码中,main 函数本身不再处理 Regex::new 可能会返回的错误信息,并且将该信息转移给 main 函数的使用者处理。由于 main 函数是 Rust 程序的入口函数,因此它的使用者是操作系统。

在 Rust 语言中,将错误信息转移给调用者处理,这种推卸责任的方法所形成的代码可使用 ? 予以简化。例如,上述代码可简化为

use regex::{Regex, Error};

fn main() -> Result<(), Error> {
    let re = Regex::new(" *@ *")?;
    let v = re.split("num@ 123@456  @ 789");
    for i in v {
        println!("{}", i);
    }
    return Ok(());
}

C 版本

同 Rust 一样,C 本身及其标准库也未提供正则表达式支持。C 语言有一个功能很丰富的第三方库 GLib,该库对正则表达式提供的实现,名曰与 Perl 兼容的正则表达式。若你有一定的阅历,可能知道 Perl 语言以正则表达式而闻名。

几乎所有的 Linux 系统发行版都提供了 GLib 库。下面给出在 Ubuntu 系统中安装 GLib 库开发包的命令:

$ sudo apt install libglib2.0-dev

调用 GLib 库的正则表达式函数,实现与上文 Rust 程序同等功能的 C 代码如下:

#include <glib.h>

int main(void) {
        GRegex *re = g_regex_new(" *@ *", 0, 0, NULL);
        gchar **v = g_regex_split(re, "num@ 123@456  @ 789", 0);
        for (size_t i = 0; v[i]; i++) {
                g_print("%s\n", v[i]);
        }
        g_strfreev(v);
        g_regex_unref(re);
        return 0;
}

g_regex_new 的第 2、3 个参数用于设定正则表达式的编译和匹配选项,g_regex_split 的第 3 个参数用于设定正则表达式的匹配选项。这些参数可以在细节上调整 GLib 的正则表达式功能,倘若并无特殊要求,它们通常可设为 0。

编译上述代码的命令为

$ gcc -std=c2x -pedantic foo.c $(pkg-config --cflags --libs glib-2.0) -o foo

根据 GLib 的文档的「Perl-compatible regular expressions」部分对 g_regex_new 函数的描述,该函数失败时会返回 NULL,但是在上述代码中,并未对这种情况进行检测。若故意让指针类型的变量 re 的值为 NULL,将其传递给 g_regex_split

GRegex *re = NULL;
gchar **v = g_regex_split(re, "num@ 123@456  @ 789", 0);

程序可以通过 gcc 的编译,但是在运行编译所得的程序时,会得到以下错误信息:

(process:11184): GLib-CRITICAL **: 11:41:56.585: g_regex_split_full: assertion 'regex != NULL' failed
Segmentation fault (core dumped)

g_regex_split 函数内部调用了 g_regex_split_full 函数,并将 re 传递给了后者,而后者检测到 reNULL,便给出了警告,但并未终止程序运行,于是程序最终以段错误而告终。

小结

本章实现了基于正则表达式的文本分割任务,同时也展示了 Rust 和 C 对待错误的不同态度。Rust 语言的错误处理方式倾向于将错误处理的决定权推卸给库或程序的用户,好处是提高了程序的容错能力,缺点是难免会给用户造成一些负担,最终免不了要写许多 unwrap。C 语言对待程序容错则是可有可无,可得可失,取决于程序员的责任感。我的看法是,写商业软件(包括开源软件),若不考虑生态,Rust 胜过 C/C++;写自由软件,C 依然堪当重任。