通过手写一个迷你 grep 来学习 Rust 的所有权与借用
在这篇文章中,我们将要了解 Rust 的所有权与借用概念、以及它们所要解决的问题,并手写一个迷你 grep 来加深理解。我不会太深入讲解理论,更侧重实践,废话不多说,让我们开始吧。
内存管理的两难
内存管理一般就两种方式,一种是以 C 为代表的手动内存管理,另一种是以 Python 为代表的自动垃圾回收(GC),但是这两种方式各有缺点。
在使用 C 编程时,我们调用 malloc 来分配内存,调用 free 来释放内存,这种方式最大的优点是速度快且可控。但问题在于我们是可能会出错的,而这就会导致重复释放内存 、忘记释放内存 、释放后仍使用内存等情况发生。
而使用 Python 编程时,会有一个垃圾回收器(GC)在后台帮我们判断哪些内存不再被引用,然后将其释放,这就避免了很多内存错误问题。但问题在于性能,GC 在扫描和释放内存时,会暂停程序执行(STW,Stop The World),而这会影响程序的响应速度和吞吐量。
简单的来说,要么是人来管理内存释放,要么是 GC 来管理内存释放,而 Rust 开创性的走出了第三条路,让编译器来管理内存释放。而这就引出了我们今天会涉及到的概念:所有权与借用。
所有权
在 Rust 中,每一块内存都有且仅有一个"所有者",也就是说所有者拥有对应内存的所有权。在编译器中,所有权有三条规则:
- 每个值都有一个所有者;
- 同一时刻,一个值只能有一个所有者;
- 当所有者离开作用域,值会被自动释放。
rust
fn main() {
let s = String::from("hello"); // s 拥有所有权
} // 离开作用域后自动释放
上面的示例很好理解,我们再来看下一个:
rust
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // 编译器报错
在大多数语言中,s1 和 s2 都会指向同一个字符串。但在 Rust 中,这种情况不会发生,所有权会从 s1 转移到 s2。
因为 String 是在堆上分配的,如果 s1 和 s2 都拥有它,谁来释放它呢?如果两者都尝试释放,就会出现重复释放内存的问题。
对于整数这类简单类型来说就不存在这个问题,因为它们存储在栈上。因此,对于这类类型,Rust 会直接进行复制:
rust
let x = 5;
let y = x;
println!("{}", x); // 编译通过
这是因为 Rust 为所有简单类型都实现了 Copy 特征,不过这里了解下就好了。
借用
所有权的唯一所有者规则虽然保证了内存安全,但是丢失了灵活性。比如,我们想调用一个计算字符串长度的函数,按照所有权的规则,我们必须像下面的例子这样实现函数:
rust
fn calculate_length(s: String) -> (usize, String) {
let len = s.len();
(len, s) // 将 s 的所有权返回
}
fn main() {
let s = String::from("hello");
let (len, s) = calculate_length(s);
println!("字符串 '{}' 的长度是 {}", s, len);
}
为了解决这一问题,Rust 引入了借用机制,在不转移所有权的前提下,允许临时访问数据。
Rust 的借用分为两种:
- 不可变借用(&T):允许借用者读取数据,但不能修改数据;
- 可变借用(&mut T):允许借用者修改数据。
现在我们将上面示例修改为借用的方式,如下所示:
rust
// 参数类型改为 &String(不可变借用)
fn calculate_length(s: &String) -> usize {
s.len() // 直接返回长度,无需返回所有权
}
fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // 传入引用 &s
println!("字符串 '{}' 的长度是 {}", s, len); // s 依然拥有所有权
}
和所有权一样,借用同样有规则:
规则一:同一时刻,要么有多个不可变借用,要么有一个可变借用,两者不可共存
这是一条经典的读写互斥规则,这是为了避免带来数据竞争:
- 多个不可变借用:所有借用者都只读,不会有数据竞争;
- 只有一个可变借用:没有其他任何借用,不存在并发读写,不会有数据竞争。
rust
// 多个不可变借用
let s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2); // 编译通过
// 单个可变借用
let mut s = String::from("hello");
let r = &mut s;
r.push_str(", world");
println!("{}", r); // 编译通过
// 可变借用与不可变借用共存
let mut s = String::from("hello");
let r1 = &s; // 不可变借用
let r2 = &mut s; // 可变借用
println!("{} {}", r1, r2); // 编译器报错
规则二:借用者的生命周期不能长于被借用者的生命周期;
如果借用者的生命周期超过了被借用者的生命周期,就会产生悬垂引用,也就是被借用者已经被释放,而借用者仍然试图访问其内存。例如:
rust
fn dangle() -> &String {
let s = String::from("hello");
&s // 返回 s 的借用
} // 离开作用域,s 被释放
fn main() {
let s = dangle();
println!("{}", s);
}
这段代码会导致编译器报错,因为 &s(借用者)的生命周期明显长于 s(被借用者)的生命周期。为什么会进行生命周期检测?这是因为生命周期检测是为了保证能安全地借用。
mini-grep 需求说明
现在我们正式进入正题,我们要实现的 mini-grep 比较简单,需求如下:
- 递归搜索:支持搜索单个文件或递归遍历指定目录下的所有普通文件;
- 正则匹配:支持标准正则表达式作为搜索模式;
- 结果输出 :输出格式为
文件名:行号:匹配行内容; - 自动跳过:自动跳过目录、符号链接及无法读取的文件(权限不足、不存在等);
- 大小写不敏感 :通过
-i或--ignore-case标志开启大小写不敏感搜索。
用法:
shell
minigrep [OPTIONS] <PATTERN> <PATH>
参数说明:
| 参数 | 说明 |
|---|---|
<PATTERN> |
搜索模式(支持正则表达式),必填 |
<PATH> |
搜索路径(文件/目录),必填 |
-i, --ignore-case |
大小写不敏感搜索,可选 |
使用示例:
1)基本搜索 ,搜索 src/main.rs 文件中包含 fn 的行:
shell
cargo run -- "fn" src/main.rs
2)递归搜索,在当前目录下所有文件中搜索包含 rust 的行:
shell
cargo run -- "rust" ./
3)大小写不敏感搜索:
shell
cargo run -- -i "RUST" ./
4)正则表达式搜索:搜索所有以 let 开头的行:
shell
cargo run -- "^let.*=" src/main.rs
项目初始化
首先,我们初始化项目,在终端中执行如下命令:
shell
cargo new mini-grep
cd mini-grep
打开 Cargo.toml 文件,添加上这次开发需要用到的依赖项:
toml
[package]
name = "mini-grep"
version = "0.1.0"
edition = "2024"
[dependencies]
# 命令行参数解析
clap = { version = "4", features = ["derive"] }
# 错误处理
anyhow = "1"
# 正则表达式
regex = "1"
# 目录遍历
walkdir = "2"
我们先在 src/main.rs 添加处理 CLI 相关的代码:
rust
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// 搜索模式(支持正则表达式)
pattern: String,
/// 搜索路径(文件或目录)
path: String,
/// 大小写不敏感搜索
#[arg(short, long)]
ignore_case: bool,
}
fn main() {
let args = Args::parse();
println!("Parsed arguments: {:?}", args);
}
我们这里使用了 clap 提供的 Parser 宏进行命令行参数解析。我们运行一下看下效果:
shell
> cargo run -- "fn" ./src
Parsed arguments: Args { pattern: "fn", path: "./src", ignore_case: false }
为了方便后续开发,我们使用一个 run 函数来编写主流程,代码如下:
rust
// 主流程
fn run(args: Args) -> anyhow::Result<()> {
Ok(())
}
fn main() {
let args = Args::parse();
// 运行主流程并处理错误
if let Err(e) = run(args) {
eprintln!("错误: {}", e);
std::process::exit(1);
}
}
解析正则表达式
现在,我们使用 regex 来完成解析正则表达式的工作,代码如下:
rust
use regex::Regex;
fn run(args: Args) -> anyhow::Result<()> {
// 构建正则模式,启用大小写不敏感时添加正则内联标志 (?i) 用于忽略大小写
let regex_pattern = if args.ignore_case {
format!("(?i){}", args.pattern)
} else {
args.pattern
};
// 编译正则表达式,编译失败通过 ? 向上传递错误
let regex = Regex::new(®ex_pattern)?;
Ok(())
}
遍历路径
接下来,我们通过 walkdir 将传入路径下的所有文件都遍历出来:
rust
use walkdir::WalkDir;
fn run(args: Args) -> anyhow::Result<()> {
...
// 递归遍历目录下的所有文件
for entry in WalkDir::new(&args.path) {
// 遍历过程中可能会出现错误,例如权限问题等
// 通过 ? 向上传递错误
let entry = entry?;
// 只处理普通文件,跳过目录、符号链接等
if !entry.file_type().is_file() {
continue;
}
// 获取文件路径
let file_path = entry.path();
// 读取文件内容
let contents = std::fs::read_to_string(file_path)?;
}
Ok(())
}
检索函数
至此,我们得到了正则与内容。接下来,我们要检索出匹配正则的内容和内容对应的行数,我们通过一个 search 函数来实现:
rust
fn search(pattern: &Regex, contents: String) -> Vec<(usize, String)> {
// 存储匹配结果
let mut results = Vec::new();
// 逐行遍历文件内容
// enumerate() 返回(索引, 行内容),索引从0开始计数
for (line_num, line) in contents.lines().enumerate() {
// 检查当前行是否匹配正则模式
if pattern.is_match(line) {
results.push((line_num + 1, line.to_string()));
}
}
results
}
接下来,我们调用 search 函数:
rust
fn run(args: Args) -> anyhow::Result<()> {
...
for entry in WalkDir::new(&args.path) {
...
// 检索匹配行,返回行号和行内容的引用
let results = search(®ex, contents);
// 输出结果
for (line_num, line) in results {
println!("{}:{}: {}", file_path.display(), line_num, line);
}
}
Ok(())
}
这里 search 函数的传参 pattern 使用借用的方式,这是因为对于 search 函数来说,它只需要能使用 pattern,不需要它的所有权。而且就算拿到了所有权,还得将所有权返回回去,不然 run 函数中的 regex 无法重复使用。
而 contents 在 run 函数中不需要重复使用,为什么在 search 函数中的传参 contents 也使用借用的方式?这是因为对于一个会被多次函数来说,它不需要考虑这些,大多数情况下,用不到所有权就使用借用就好了。
至此,我们这个 mini-grep 就完成了,我们可以在终端执行下,看看效果如何:
shell
> cargo run -- "fn" ./src
./src/main.rs:20: fn search(pattern: &Regex, contents: &str) -> Vec<(usize, String)> {
./src/main.rs:33: fn run(args: Args) -> anyhow::Result<()> {
./src/main.rs:73: fn main() {
这次我们使用编译之后的可执行文件来执行:
shell
> cargo build
> ./target/debug/mini-grep -- "fn" ./src
./src/main.rs:20: fn search(pattern: &Regex, contents: &str) -> Vec<(usize, String)> {
./src/main.rs:33: fn run(args: Args) -> anyhow::Result<()> {
./src/main.rs:73: fn main() {
最后,来看完整代码:
rust
use clap::Parser;
use regex::Regex;
use walkdir::WalkDir;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// 搜索模式(支持正则表达式)
pattern: String,
/// 搜索路径(文件或目录)
path: String,
/// 大小写不敏感搜索
#[arg(short, long)]
ignore_case: bool,
}
// 检索函数
fn search(pattern: &Regex, contents: &str) -> Vec<(usize, String)> {
let mut results = Vec::new();
for (line_num, line) in contents.lines().enumerate() {
if pattern.is_match(line) {
results.push((line_num + 1, line.to_string()));
}
}
results
}
// 主流程
fn run(args: Args) -> anyhow::Result<()> {
// 构建正则模式,启用大小写不敏感时添加正则内联标志 (?i) 用于忽略大小写
let regex_pattern = if args.ignore_case {
format!("(?i){}", args.pattern)
} else {
args.pattern
};
// 编译正则表达式,编译失败通过 ? 向上传递错误
let regex = Regex::new(®ex_pattern)?;
// 递归遍历目录下的所有文件
for entry in WalkDir::new(&args.path) {
// 遍历过程中可能会出现错误,例如权限问题等
// 通过 ? 向上传递错误
let entry = entry?;
// 只处理普通文件,跳过目录、符号链接等
if !entry.file_type().is_file() {
continue;
}
// 获取文件路径
let file_path = entry.path();
// 读取文件内容
let contents = std::fs::read_to_string(file_path)?;
// 检索匹配行,返回行号和行内容的引用
let results = search(®ex, &contents);
// 输出结果
for (line_num, line) in results {
println!("{}:{}: {}", file_path.display(), line_num, line);
}
}
Ok(())
}
fn main() {
let args = Args::parse();
// 运行主流程并处理错误
if let Err(e) = run(args) {
eprintln!("错误: {}", e);
std::process::exit(1);
}
}
结语
如果你有些代码看不懂,这其实没关系,只要能看懂所有权和借用相关部分、并且照着文章能把 mini-grep 写出来就足够了。
在这个过程中,你的脑子会产生各种思考,这些思考又必然会引发更多的问号,这是好事。带着这些问号,才能更有目的地学习,学得更加深刻且有效。