在上一章的结尾,我们遇到了一个难题:如果我们只是想让一个函数读取一下数据,却不得不将数据的所有权交出去,用完后再由函数返还。这个过程就像是为了让朋友开一下你的车,却需要办理一次完整的车辆过户手续,开完后再给你过户回来一样,既笨拙又低效。
// 上一章的笨拙模式
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
这显然不是理想的编程方式。幸运的是,Rust 提供了一个优雅得多的解决方案,它就是借用(Borrowing)。
借用的概念很简单,正如本章的标题一样:你可以查看我的数据,甚至在得到许可的情况下修改它,但你不能把它拿走(即不能获得它的所有权)。
实现借用的机制,就是我们既熟悉又陌生的引用(References)。
不可变引用(&T
)——“只读”的借阅
在 Java 中,我们传递的几乎都是对象的“引用”。Rust 的引用概念类似,但受到了所有权系统极其严格的监管。
我们可以创建一个指向某个值的引用,而无需获取其所有权。因为不发生所有权转移,所以当引用离开其作用域时,它所指向的值也不会被丢弃(drop)。
创建一个引用的操作符是 &
。
现在,让我们用引用的方式,来重写那个笨拙的 calculate_length
函数:
fn main() {
let s1 = String::from("hello");
// 我们传递的不是 s1 本身,而是 s1 的引用
// &s1 创建了一个指向 s1 的引用,但 s1 仍然是数据的所有者
let len = calculate_length(&s1);
// s1 在这里依然完全有效,因为它的所有权从未被移动
println!("The length of '{}' is {}.", s1, len);
}
// 函数签名接收的类型是 &String,而不是 String
fn calculate_length(s: &String) -> usize { // s 是一个 String 的引用
s.len()
} // s 在这里离开作用域。但因为它并不拥有所指向的数据,
// 所以什么也不会发生,堆上的数据不会被释放。
看,这多么清爽!main
函数中的 s1
依然是数据的所有者,它只是将数据“借给”了 calculate_length
函数一小会儿。
我们把“创建一个引用”这个行为称为借用(borrowing)。
默认情况下,借用是不可变的。就像你在图书馆借了一本珍贵的古籍,你只能阅读,不能在上面涂画。
fn change(s: &String) {
s.push_str(", world"); // 编译错误!
}
编译器会阻止我们这么做,并提示 s
是一个不可变引用,不能修改它所借用的内容。
与 Java 的对比: 这感觉很像在 Java 里传递一个对象引用。但关键区别在于:Rust 在编译期就强制了这种不可变性。 在 Java 中,你无法在方法签名上阻止调用者执行一个 setter
方法来修改对象。而在 Rust 中,&String
这个类型签名本身就是一份“我绝不会修改数据”的契约,由编译器强制执行。
可变引用(&mut T
)——“可读写”的授权
那么,如果我们确实需要一个函数来修改它借用的数据呢?Rust 当然也提供了这种可能,但这需要一个更明确的“授权”:可变引用(mutable reference)。
使用 &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");
}
这里有两点需要注意:
- 变量
s
本身必须用let mut
声明为可变的。 - 创建引用时必须使用
&mut
。
这再次体现了 Rust 的**明确性(explicitness)**原则。你必须在两个地方都清楚地表明你的意图,才能修改数据。
借用规则——编译器执行的“图书馆法则”
借用虽然强大,但也必须遵守一套严格的规则。正是这套规则,让 Rust 能够在没有 GC 的情况下,从源头上杜绝数据竞争(Data Races)。数据竞争通常发生在多线程环境下:
- 两个或更多的指针(引用)同时访问同一块数据。
- 其中至少有一个指针在写数据。
- 没有使用任何同步机制。
Rust 的借用规则在编译期就彻底防止了这种情况的发生。
借用第一定律:在任何给定的作用域内,对于一块数据,你只能拥有以下两种情况之一:
- 一个可变引用 (
&mut T
)。- 任意数量的不可变引用 (
&T
)。
借用第二定律:引用必须始终有效。
让我们看看违反规则时会发生什么:
违反定律一:一个可变引用 vs 任意数量的不可变引用
-
你不能同时拥有多个可变引用:
let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; // 编译错误! println!("{}, {}", r1, r2);
为什么? 如果
r1
和r2
都能同时修改s
,那么修改的顺序和结果将变得不可预测,这就是数据竞争的温床。Rust 在编译期就禁止了这种可能。 -
你不能在拥有不可变引用的同时,再创建一个可变引用:
let mut s = String::from("hello"); let r1 = &s; // 不可变借用 let r2 = &s; // 不可变借用,OK let r3 = &mut s; // 编译错误! println!("{}, {}, and {}", r1, r2, r3);
为什么? 如果你已经把书借给
r1
和r2
去“只读”,你就得保证书的内容不会在他们阅读期间被篡改。r3
的存在打破了这个承诺。这保证了数据的使用者在持有不可变引用期间,数据是稳定不变的。
一个重要的细节:引用的作用域
引用的作用域从它被创建开始,一直持续到它最后一次被使用的地方。这意味着,如果引用的作用域不重叠,你可以创建多个引用:
let mut s = String::from("hello");
let r1 = &s; // r1 的借用开始
let r2 = &s; // r2 的借用开始
println!("{} and {}", r1, r2); // r1 和 r2 最后一次被使用,它们的借用作用域到此结束
let r3 = &mut s; // 现在是合法的!因为 r1 和 r2 的生命已经结束
r3.push_str(", world");
println!("{}", r3);
违反定律二:悬垂引用(Dangling References)
引用必须指向一块有效的内存。Rust 编译器同样会保证你不会意外地创建一个指向无效内存的“悬垂引用”。
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String { // dangle 函数返回一个 String 的引用
let s = String::from("hello"); // s 是一个局部变量
&s // 返回 s 的引用
} // s 在这里离开作用域,它拥有的 String 被 drop,内存被释放!
在 dangle
函数的结尾,s
的生命结束了,它占用的内存被释放。那么函数返回的引用将指向一块已经被回收的、无效的内存。在 C/C++ 中,这是一个能导致程序崩溃或安全漏洞的严重 bug。
而 Rust 编译器绝不会让这样的代码通过编译。它会提示你,返回的引用指向的数据生命周期不够长。正确的做法是直接返回 String
本身,将所有权移出函数:
fn gives_ownership() -> String {
let some_string = String::from("hello");
some_string // 所有权被移动到调用者
}
本章小结
我们今天掌握了所有权系统的另一半——借用。这个机制让 Rust 的代码既安全又高效。
- 借用允许我们在不转移所有权的情况下使用值。
- 引用是实现借用的工具,分为不可变引用(
&T
)和可变引用(&mut T
)。 - 借用规则(一个可变 vs. 多个不可变)是 Rust 在编译期防止数据竞争的核心武器,也是其“无畏并发”(Fearless Concurrency)的基石。
- 编译器会防止我们创建悬垂引用,保证所有引用都指向有效数据。
我们现在已经学习了 Rust 安全性的两大支柱:所有权和借用。你可能已经感觉到,编译器有时就像一个喋喋不休的老师,不断地检查你的“作业”。但它的每一次唠叨,都是在为你消除一个潜在的、致命的运行时 bug。
但是,当代码变得更复杂,比如在一个结构体里存放一个引用时,编译器可能无法自动推断出引用的生命周期是否有效。此时,我们就需要一种方式,来手动地告诉编译器各个引用之间的“存活关系”。
在下一章,我们将挑战这个主题的“最终 Boss”——(《生命周期(Lifetimes)——与编译器做个“约定”》)[https://silentstormic.top/post/from_java_to_rust/08/]。掌握它,你将真正成为一名合格的 Rustacean。