为什么需要所有权系统
内存安全问题的根源,几乎都可以归结为一句话:同一块内存被多个地方同时使用,却没有约定谁负责释放。C 把这件事交给程序员,于是有了悬垂指针和泄漏;有垃圾回收的语言把释放交给运行时,代价是额外的开销与不可预测的停顿。
Rust 选择了第三条路:把“谁拥有这块内存”变成编译期可检查的规则。代码如果可能产生悬垂引用或数据竞争,就通不过编译——零运行时开销,也没有 GC。这就是所有权系统的全部动机,理解了它,后面所有“编译器为什么拦我”的疑问都会迎刃而解。
Rust 的编译器不是刁难你,而是在替你做代码审查。它拦下的每一处,几乎都是别的语言里会在运行时崩溃或悄悄出错的地方。
- 所有权每个值有唯一所有者,所有者离开作用域即释放。
- 借用用引用临时使用值,同一时刻要么多个不可变、要么一个可变。
- 生命周期标注引用之间的关系,保证引用不会超过被引用者。
- Result/Option用类型表达“可能失败/可能为空”,错误不被忽略。
所有权三规则与移动语义
所有权可以浓缩成三条规则:每个值都有一个所有者;任一时刻只有一个所有者;所有者离开作用域时,值被丢弃(drop)。当把值赋给另一个变量时,所有权转移而不是复制——原变量随即失效。
fn main() {
let s = String::from("hello"); // s 拥有这段堆内存
let t = s; // 所有权移动给 t
// println!("{}", s); // 编译错误:s 已被移动
println!("{}", t); // OK
let n = 5; // 简单类型实现了 Copy,赋值即复制
let m = n;
println!("{} {}", n, m); // 都可用:5 5
} // t 离开作用域,内存自动释放
为什么 String 会“移动”而 i32 会“复制”?因为 String 内部持有一个指向堆的指针,如果赋值时按位复制,就会出现两个变量指向同一块内存,析构时被释放两次。像 i32 这类定长、可完整复制且无资源的数据类型实现了 Copy trait,赋值时直接复制,不涉及所有权转移。把值传给函数也一样:传 String 即转移,函数结束时该值就被 drop。
借用与引用:可变与不可变
总转移所有权很不方便,于是有了借用:用引用 &T 临时访问值而不取得所有权。借用有两条铁律,它们是防数据竞争的核心:
| 规则 | 含义 | 收益 |
|---|---|---|
| 多个不可变借用可共存 | 可同时有任意多个 &T | 并发只读安全 |
| 可变借用独占 | 有 &mut T 时不能再有其他引用 | 杜绝读写同时发生 |
| 借用不超出所有者生命周期 | 被引用者必须活得比引用长 | 杜绝悬垂引用 |
fn calc_len(s: &String) -> usize { // 只借用,不取得所有权
s.len()
}
fn add_suffix(s: &mut String) { // 可变借用,允许修改
s.push_str("!");
}
fn main() {
let mut text = String::from("rust");
let n = calc_len(&text); // 不可变借用
println!("len = {}", n); // 4
add_suffix(&mut text); // 可变借用
println!("{}", text); // rust!
}
编译器报的 “cannot borrow as mutable because it is also borrowed as immutable”,翻译过来就是“同一时间既有只读引用又有可写引用”。这在多线程里正是数据竞争的成因,Rust 把它提前到了编译期。实践中遇到这类错误,先想“我是不是同时需要读和写”,通常把借用的作用域收窄就能解决。
生命周期标注入门
生命周期是编译器用来验证“引用不会比被引用者活得更久”的机制。大多数情况下它自动推导,你无需手写;只有当一个函数返回引用、而参数里有多个引用时,编译器需要你说明返回的引用与哪个参数“同寿”。
// 返回的引用与两个入参中较短的那个同寿
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() >= y.len() { x } else { y }
}
fn main() {
let s1 = String::from("长一点的字符串");
let s2 = "short";
let result = longest(s1.as_str(), s2);
println!("较长者:{}", result);
}
'a 是生命周期参数,读作“x、y 和返回值至少都能活到 'a”。它不会延长任何对象的寿命,只是向编译器描述引用之间的关系。入门阶段记住了规律就好:函数返回引用时最常需要标注,其余交给编译器;如果标注让代码变得别扭,通常说明设计上应该返回拥有所有权的值(如 String)而不是引用。
用 Result 与 Option 处理错误
Rust 没有异常,它把“可能失败”和“可能为空”分别编码进类型:Result<T, E> 表示成功(Ok)或失败(Err),Option<T> 表示有值(Some)或无值(None)。编译器强制你处理这两种情况,于是“忘记检查错误”这类 bug 从源头上消失了。
fn parse_port(s: &str) -> Result<u16, String> {
match s.parse::<u16>() {
Ok(p) if p > 0 => Ok(p),
Ok(_) => Err("端口必须大于 0".to_string()),
Err(_) => Err(format!("不是合法端口:{}", s)),
}
}
fn main() {
match parse_port("8080") {
Ok(p) => println!("端口 = {}", p),
Err(e) => println!("错误:{}", e),
}
// ? 运算符:出错即提前返回,成功则取出值
let p: u16 = parse_port("9090").unwrap_or(80);
println!("使用端口 {}", p); // 9090
}
match 用于完整分支处理,? 运算符用于“错误向上传播”,unwrap() / expect() 则在“确认不会失败”时快速取值(生产代码里慎用,失败会 panic)。把这三种工具用顺,你就能写出既不丢错误、又不啰嗦的错误处理。
Cargo 工程与模块
Cargo 是 Rust 的官方构建与包管理工具,一个命令就能建工程、编译、运行、测试、发布。项目结构约定清晰:源码在 src/,依赖写在 Cargo.toml,编译产物在 target/。
# 新建二进制项目与库项目
cargo new wordcount
cargo new --lib mylib
# 常用命令
cargo build # 编译(debug)
cargo run # 编译并运行
cargo build --release # 优化编译
cargo test # 跑测试
cargo fmt && cargo clippy # 格式化 + 静态检查
cargo fmt统一代码风格,cargo clippy给出更严格的改进建议,二者建议写进提交前流程。- 依赖在
Cargo.toml的[dependencies]声明,版本用语义化约束(如serde = "1")。 - 模块用
mod组织,公开接口加pub;默认私有,这让封装成为默认行为。
写测试也很自然:在同文件里加 #[cfg(test)] mod tests,用 #[test] 标注测试函数,cargo test 一次跑完。Rust 把“写代码—编译—测试—格式化”整合进一条工作流,这也是它在工程体验上口碑很好的原因。
实战:一个词频统计命令行工具
把所有权、借用、Result 和标准库集合串起来,写一个读取文本并统计词频的小工具。它读取命令行参数指定的文件,按空白分词,统计每个词出现次数,并按次数降序输出前十名。
use std::collections::HashMap;
use std::env;
use std::fs;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = env::args().nth(1).unwrap_or_else(|| "input.txt".to_string());
let text = fs::read_to_string(&path)?; // ? 传播 IO 错误
let mut counts: HashMap<&str, usize> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1; // 借用 text,不复制
}
let mut pairs: Vec<(&str, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1)); // 按次数降序
for (word, cnt) in pairs.iter().take(10) {
println!("{:>5} {}", cnt, word);
}
Ok(())
}
这段代码藏着几个 Rust 要点:? 把 IO 错误直接上抛,main 返回 Result 让它成为可能;HashMap<&str, usize> 借用原文而不是复制字符串,省下大量内存;entry().or_insert() 是一次查找完成“有则加一、无则插入”。最后排序时 b.1.cmp(&a.1) 用 & 明确是比较而非移动。
从入门到进阶的路线
Rust 的学习曲线陡在前段,但一旦越过所有权这道坎,后面的路会越来越顺。推荐的学习顺序是:
- 基础语法:变量、类型、控制流、函数,配合
cargo熟悉工程流程。 - 所有权与借用:反复写涉及 String / Vec 的函数,直到能做对参数该传值还是传引用。
- 枚举与模式匹配:把
Option、Result、match用到熟练,这是 Rust 表达力的核心。 - trait 与泛型:理解抽象与约束,再过渡到迭代器与闭包。
- 并发与异步:用标准库线程与
Arc<Mutex>起步,再学async/await生态。
常见误区:为了绕过编译器错误滥用 clone(),会让性能优势荡然无存;遇到借用错误就到处加 &,而不去想“这里到底谁拥有数据”。多问一句“这个值的主人是谁”,往往比改语法更快解决问题。
Rust 把内存安全的代价从“运行时崩溃”挪到了“编译期提示”。刚开始觉得编译器啰嗦,等你习惯了,就会感谢它替你在上线前拦住了那些本该半夜排查的问题。
下一篇我们转向 MATLAB,看它如何用矩阵语言与仿真工具服务工程与科学计算。