
Rust的引用系统是Go程序员转向Rust时的主要挑战之一。本文从Go程序员的视角,系统性地解释Rust的引用概念、所有权机制及其在工程实践中的应用。
Go的指针 vs Rust的引用
在Go中,一般使用指针:
func modify(s *string) {
*s = "changed"
}
Rust的引用看起来差不多:
fn modify(s: &mut String) {
*s = "changed".to_string();
}
但这里有个关键区别:Go的指针可以为nil,Rust的引用永远不能为空。编译器会保证这一点。Rust的引用是安全的,不会出现空指针,因此不需要进行nil检查。
所有权:Rust的核心机制
对于Go程序员而言,所有权系统是需要重点适应的概念。在Go中,传递slice、map等类型时,基本不需要思考所有权:
func process(data []int) {
// 直接使用,无需思考所有权
}
但在Rust中,一个值只能有一个所有者:
let s = String::from("hello");
let s2 = s; // s的所有权被移走了
// println!("{}", s); // 编译错误!s已经不能用了
对于Go程序员来说,这种所有权移动的语义需要适应。在大多数场景下,使用引用(借用)即可避免所有权转移:
let s = String::from("hello");
let s2 = &s; // 借用,不拿走所有权
println!("{}", s); // 可以继续使用
借用的规则:编译期的严格检查
Rust的借用检查器在编译期执行严格的规则检查,其严格程度甚至超过Go的race detector。核心规则有两条:
- 要么多个不可变引用,要么一个可变引用
- 引用不能比被引用的值活得更久
第一条规则类似于读写锁的语义,Go程序员一般能够理解。第二条规则涉及生命周期,是Go程序员容易出错的地方。
fn get_ref() -> &String {
let s = String::from("hello");
&s // 编译错误!s在函数结束就没了,引用会悬空
}
这种代码在Go中是合法的,但在Rust中会被编译器拦截。这些规则虽然严格,但能够有效避免悬空指针、use-after-free等内存安全问题。
没有所有权,为什么还能修改?
这是Go程序员常见的困惑:既然引用没有所有权,为什么&mut T还能修改内容?
关键要理解:所有权(Ownership)和借用(Borrowing)是分开的。
只读引用:真的不能改
先看只读引用(不可变借用):
let mut s = String::from("hello");
let r = &s; // 不可变借用
// r.push_str(" world"); // 编译错误!不可变借用不能修改
不可变借用是只读的,即使原来的变量是mut,通过不可变借用也无法修改。这是不可变借用的基本特性。
可变引用:可变借用
但可变引用(可变借用)就不一样了:
let mut s = String::from("hello");
let r = &mut s; // 可变借用
r.push_str(" world"); // 可以修改!
println!("{}", s); // "hello world"
这里r没有所有权,但为什么能修改?由于可变借用借来了修改的能力。
Rust的借用检查器保证:当存在一个可变借用时,原来的变量就不能直接修改了:
let mut s = String::from("hello");
let r = &mut s;
// s.push_str(" error"); // 编译错误!s被借走了
r.push_str(" world"); // 只能通过r修改
这类似于借车:在借出的这段时间,原所有者不能使用,但所有权仍在原所有者手中,只是使用权暂时转移。
所有权 vs 借用:两个层面的概念
在Rust中,这两个概念是分开的:
- 所有权(Ownership):决定谁负责释放内存,一个值只能有一个所有者
- 借用(Borrowing):决定谁能访问数据,可以是不可变借用(&T)或可变借用(&mut T)
let s = String::from("hello"); // s拥有所有权
let r1 = &s; // r1是不可变借用,只能读
let r2 = &s; // r2也是不可变借用,可以有多个
// s的所有权还在,但通过r1、r2只能读
let mut s = String::from("hello");
let r = &mut s; // r是可变借用,可以修改
// 目前只有r能修改,s自己也不能改了
// 但所有权还在s那里,s负责释放内存
当可变借用离开作用域,借用就”还”回来了:
let mut s = String::from("hello");
{
let r = &mut s;
r.push_str(" world");
} // r离开作用域,借用归还
s.push_str("!"); // 目前s又可以修改了
为什么这样设计?
这种设计有几个好处:
- 防止数据竞争:同一时间只能有一个可变引用,避免了并发修改
- 明确权限:代码里看到&mut,就知道这里会修改数据
- 零成本:引用就是指针,没有运行时开销
在Go中,传递slice时,函数内部可能修改数据,调用者无法从函数签名中直接判断。但在Rust中,函数签名中的&mut明确表明该函数会修改数据,调用者可以清楚地知道这一点。
// 函数签名明确表明会修改vec
fn process(vec: &mut Vec<i32>) {
vec.push(42);
}
// 函数签名表明只读,不会修改
fn read_only(vec: &Vec<i32>) {
println!("{:?}", vec);
}
所以,可变引用能修改内容,不是由于它有所有权,而是由于它通过可变借用获得了修改的能力。借用检查器保证这个借用是独占的、安全的。
(注:Rust官方术语是”所有权”和”借用”,没有”修改权”这个概念。这里用”修改权”是为了方便理解,实际上就是可变借用带来的能力。)
所有权的本质:资源生命周期管理
所有权的本质是什么?这是理解Rust内存管理的关键。
本质:资源管理的责任
所有权的本质,就是决定谁负责释放资源(主要是内存)。
在C/C++中,需要手动进行malloc和free,容易出现忘记释放或重复释放的问题。在Go/Java中,有GC自动管理,但GC有运行时开销,且不能保证及时释放。
Rust的所有权系统,在编译期就确定了:每个值有且仅有一个所有者,当所有者离开作用域,值就被自动释放。这就是RAII(Resource Acquisition Is Initialization)的体现。
{
let s = String::from("hello"); // s拥有这个String
// 使用s...
} // s离开作用域,String被自动释放(调用drop)
为什么只能有一个所有者?
如果允许多个所有者,就会出现问题:
// 假设允许多个所有者(实际不行)
let s = String::from("hello");
let s2 = s; // 如果s2也是所有者
// 当s离开作用域,String被释放
// 但s2还在用,就悬空指针了!
因此Rust强制:一个值只能有一个所有者。当将值赋给另一个变量时,所有权就移动了,原来的变量就不能使用了。
所有权 vs 值本身
需要理解的关键点是:所有权是编译期的概念,而非运行时的机制。
let s = String::from("hello");
let s2 = s; // 所有权移动
在运行时,String的数据还在堆上,没有复制。移动的是”所有权”这个概念——编译器知道目前s2负责释放这个String,s不再负责了。
所以Rust的所有权系统是零成本的:编译期检查,运行时没有额外开销。
与Go的对比
在Go中:
s := "hello"
s2 := s // 字符串是值类型,会复制
// 或者
data := make([]int, 100)
data2 := data // slice是引用类型,共享底层数组
Go的slice、map、channel都是引用类型,多个变量可以指向同一个底层数据。GC负责在不再使用时释放。
在Rust中:
let s = String::from("hello");
let s2 = s; // 所有权移动,s不能用了
// 如果想共享,需要用Rc或Arc
let shared = Rc::new(String::from("hello"));
let shared2 = Rc::clone(&shared); // 共享所有权
Rust默认是移动语义,共享需要显式用Rc/Arc。这样设计的好处是:默认情况下不会有数据竞争,由于只有一个所有者。
所有权的真正价值
所有权的真正价值,不在于限制编程方式,而在于:
- 编译期保证内存安全:不会有悬空指针、use-after-free
- 零成本抽象:编译期检查,运行时无开销
- 明确资源生命周期:看代码就知道资源什么时候释放
- 防止数据竞争:配合借用规则,编译期禁止数据竞争
所以,所有权的本质就是:在编译期确定资源(主要是内存)的生命周期,保证安全且高效的内存管理。
实际工程中的经验
1. 优先使用引用而非移动
在函数参数传递时,优先使用引用而非移动所有权。这样可以避免不必要的所有权转移,使代码更加灵活:
// 不好的写法:需要返回Vec,由于所有权被拿走了
fn process_bad(data: Vec<i32>) -> Vec<i32> {
// 处理数据...
data // 必须返回
}
// 更好的写法:使用引用,不需要移动所有权
fn process_good(data: &[i32]) -> Vec<i32> {
// 处理数据,返回新Vec
data.iter().map(|x| x * 2).collect()
}
2. 生命周期标注的使用场景
生命周期标注(如'a)看起来复杂,但在大多数情况下,编译器能够自动推断,无需手动标注。只有在编写返回引用的函数时,才需要显式标注:
// 编译器自动推断,不需要标注
fn first(s: &str) -> &str {
&s[..1] // 返回第一个字符(如果字符串不为空)
}
// 需要标注的情况
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
生命周期标注'a表明:返回的引用和输入的引用具有一样的生命周期。理解这一点后,生命周期标注就不再神秘。
3. 借用冲突的解决方案
编写Rust代码时,最常见的错误是”cannot borrow as mutable because it is also borrowed as immutable”。遇到这种情况,应该通过重构代码来解决,而不是尝试绕过编译器检查:
// 有问题的代码
let mut vec = vec![1, 2, 3];
let first = &vec[0]; // 不可变借用
vec.push(4); // 错误!尝试可变借用
// 解决方案1:缩小作用域
let first = {
let temp = &vec[0];
*temp
};
vec.push(4);
// 解决方案2:用索引而不是引用
let first = vec[0];
vec.push(4);
4. Rc和Arc:共享所有权的场景
在某些场景下,的确 需要多个所有者,例如在闭包中捕获数据,或者多线程间共享数据。此时可以使用Rc(单线程场景)或Arc(多线程场景):
use std::rc::Rc;
let data = Rc::new(String::from("shared"));
let data2 = Rc::clone(&data); // 引用计数+1,不复制数据
这与Go的引用语义类似,但Rust明确区分了”共享所有权”和默认的移动语义,需要显式使用Rc或Arc。
为什么Rust要这么设计?
Rust的严格规则看似增加了编程复杂度,但这些规则在编译期就能避免许多运行时问题:
- 数据竞争:Rust在编译期就禁止了数据竞争,无需运行时检查
- 内存安全:虽然没有GC,但通过所有权系统避免了悬空指针、use-after-free等内存安全问题
- 性能:零成本抽象,引用在运行时就是指针,没有额外开销
Go的GC虽然方便,但在需要极致性能和内存安全的场景下,Rust这种”编译期保证安全”的方式提供了更强的保障。
总结
从Go转向Rust,引用系统是需要重点理解的概念。一旦理解了所有权和借用的机制,Rust的引用系统就会变得直观——借用就是临时使用值,而不获取所有权。
编译器的严格检查虽然增加了开发成本,但这些检查能够协助开发者发现潜在的代码设计问题。通过调整代码结构,大多数借用冲突都可以得到解决。
Rust的编译期保证提供了强劲的内存安全保障。编译通过后,基本可以确信代码不会出现内存安全问题。这种”编译期保证”是Rust相比Go的独特优势。
当然,Rust并非适用于所有场景。对于需要快速迭代、大量动态行为的场景,Go可能更合适。但对于需要极致性能、内存安全,或者系统级编程的场景,Rust的引用系统值得深入学习。
不同的编程语言提供了不同的视角和工具。Go和Rust各有其适用场景,理解它们的设计理念有助于做出更好的技术选择。



引用计数效率太低