第三章:引用与借用——不可变引用、可变引用、悬垂引用

所有权系统是Rust的基石,但说实话,如果每次传递参数都要转移所有权,那代码写起来也太痛苦了。你想想看,一个函数调用完,变量就没了,这谁受得了?

好在Rust提供了「引用」这个机制。它就像一把钥匙,让你能开门看看里面的东西,但不把房子搬走。我个人习惯把引用理解为「临时访问权限」,用完即还,所有权纹丝不动。

3.1 不可变引用:只读访问

不可变引用,说白了就是只读权限。你可以看,但不能改。

fn main() {
    let s = String::from("hello");
    let len = calculate_length(&s);  // 传引用,不转移所有权
    println!("'{}' 的长度是 {}", s, len);  // s 还能用
}

fn calculate_length(s: &String) -> usize {
    s.len()
}  // 这里 s 离开作用域,但它不拥有所有权,所以不会 drop

注意看那个 &s,这就是创建引用的语法。函数签名里的 &String 表示「这是一个指向String的引用」。

我在项目中遇到过一个问题:新手容易把引用和所有权搞混,以为传了引用就能随便改。其实不行,编译器会直接报错——

fn main() {
    let s = String::from("hello");
    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");  // 编译错误!
}

错误信息很明确:cannot borrow `*some_string` as mutable, as it is behind a `&` reference。嗯,这里要注意,不可变引用就是只读的,想改?得用可变引用。

小技巧:不可变引用实现了 Copy trait,所以你可以创建任意多个不可变引用,它们之间互不干扰。

3.2 可变引用:读写权限

如果你真的需要修改借来的值,那就用可变引用。语法是 &mut

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
    println!("{}", s);  // 输出 "hello, world"
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

这里有两个关键点:

  • 原变量必须声明为 mut
  • 函数参数类型必须是 &mut String

但可变引用有个严格的限制——同一作用域内,只能有一个可变引用。为什么?

我曾经在代码评审时见过这样的写法:

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;  // 编译错误!
println!("{}, {}", r1, r2);

编译器会告诉你:cannot borrow `s` as mutable more than once at a time。这个设计是为了防止数据竞争。你想想看,如果两个地方同时修改同一块内存,那结果谁说得准?

避坑指南:我曾经在并发场景下吃过数据竞争的亏。虽然Rust在单线程里就帮你堵死了这个漏洞,但如果你用 unsafe 代码绕过检查,那后果自负。记住:可变引用是独占的。

3.3 不可变引用与可变引用的共存规则

这里有个容易踩的坑:不可变引用和可变引用不能同时存在。但规则比你想的更精细——

let mut s = String::from("hello");
let r1 = &s;      // 没问题
let r2 = &s;      // 没问题
let r3 = &mut s;  // 编译错误!
println!("{}, {}, and {}", r1, r2, r3);

为什么?因为不可变引用假设「没人会改数据」,而可变引用说「我要改数据」。这两个假设是矛盾的。Rust编译器不允许这种矛盾存在。

但如果你不再使用不可变引用,就可以创建可变引用:

let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);  // r1, r2 最后一次使用
// 这里 r1, r2 的作用域结束
let r3 = &mut s;  // 没问题!
println!("{}", r3);

这个特性叫「非词法作用域生命周期」(NLL),Rust 2018引入的。说白了就是:编译器会追踪引用最后一次使用的位置,在那之后就可以创建新的可变引用了。

核心原则:
  • 要么有任意多个不可变引用(&T)
  • 要么有且只有一个可变引用(&mut T)
  • 两者不能同时存在

3.4 悬垂引用:Rust如何消灭野指针

悬垂引用,就是指向已经释放的内存的指针。在C/C++里这叫「野指针」,是段错误的常见元凶。Rust在编译期就帮你消灭了这个问题。

看这个危险的例子:

fn dangle() -> &String {
    let s = String::from("hello");
    &s  // 返回指向 s 的引用
}  // 这里 s 被 drop,引用变成悬垂指针

编译器会直接拒绝:missing lifetime specifier。Rust说:「你返回的引用指向一个即将被释放的值,这不行。」

正确的做法是把所有权转移出去:

fn no_dangle() -> String {
    let s = String::from("hello");
    s  // 转移所有权,没问题
}

我记得刚学Rust时,总想返回引用省点拷贝开销。后来发现,Rust的编译器比我想得周到——它强制我思考「这个引用到底指向谁?它的生命周期够不够长?」

避坑指南:我曾经在写链表时试图返回内部节点的引用,结果被生命周期搞到崩溃。后来想通了:如果数据的所有权在函数内部,就别想着返回引用。要么返回所有权,要么用 Rc<T>Arc<T> 共享所有权。

3.5 引用的规则总结

把引用规则整理成一张表,方便你随时查阅:

规则 说明 违反后果
同一时间只能有一个可变引用 防止数据竞争 编译错误
不可变引用和可变引用不能共存 保证读取时数据不变 编译错误
引用必须始终有效 防止悬垂指针 编译错误
引用不拥有所有权 离开作用域不会drop数据 无(这是特性)

3.6 知识体系图

下面这张图展示了引用与借用的核心逻辑,我建议你多看几遍,理解其中的关系:

引用与借用 不可变引用 &T 可变引用 &mut T 悬垂引用(禁止) • 只读访问 • 可同时存在多个 • 实现了 Copy trait • 读写访问 • 同一时间只能有一个 • 与不可变引用互斥 • 指向已释放内存 • 编译期被拦截 • 需转移所有权解决 核心原则:引用不拥有所有权,但受严格规则约束

这张图把三种引用类型放在一起对比。你看,不可变引用和可变引用是「合法」的,但规则不同;悬垂引用则是Rust坚决禁止的。理解这三者的区别,你就掌握了Rust内存安全的核心。

个人经验:我刚开始写Rust时,经常被借用检查器搞得焦头烂额。后来我养成了一个习惯:写代码前先想清楚「谁拥有数据?谁需要访问?访问是读还是写?」想清楚了再动手,编译错误少了一大半。

嗯,引用与借用的内容就到这里。记住:Rust的引用规则不是限制你,而是保护你。等你习惯了这套规则,你会发现自己写的代码越来越健壮,越来越不容易出bug。


公众号:蓝海资料掘金营,微信deep3321