1. 先从“内存安全”说起:为什么Rust要以所有权为核心
很多从C/C++转过来的朋友第一次接触Rust,都会有一个相同的困惑:我写C/C++写得好好的,为什么要引入所有权这么个“别扭”的东西?我理解这个疑问,但你只要在这行待得够久,大概率体会过深夜排查悬垂指针的痛苦,或者面对一处段错误崩溃、却完全不知道是哪里提前释放了内存的绝望。
Rust的所有权系统,本质上就是冲着解决这些经典内存问题来的。C/C++给了你极大的自由度,也同时把内存管理的责任完全交给了你。你new出来的内存在哪里释放、什么时候释放、有没有别的指针还在用这块内存,全靠人肉记忆。人一旦多了、业务一旦复杂,这种记忆必然出漏子。而Rust的解决思路不是“多加一些检查工具”,而是直接在语言类型系统层面加入一套规则,让违背内存安全的代码在编译期就过不了关。
我见过不少人对这个设计的评价是“编译器太啰嗦”。但说实话,等你真的写出过一版 Rust 程序、并且在编译器提示下把悬垂引用、重复释放这类问题全部消灭之后,你会对这种“啰嗦”产生一种依赖。因为它拦下来的,正是你在C/C++里可能要花几个通宵才能定位的坑。
这也就引出了所有权系统最核心的三条规则,它们看起来简单,却是后面所有概念的地基:
- Rust 中每一个值都有一个变量,这个变量被称为它的所有者。
- 同一时间只能有一个所有者。
- 当所有者离开作用域时,这个值会被丢弃并释放内存。
你可以把这三条规则理解为“一家公司里一把钥匙只能有一个保管人,保管人离职时必须把钥匙交接清楚”。谁负责保管这块内存,谁就负责清理。规则清晰,就没有人抢着打扫、也没有人以为不用打扫。
为什么 Rust 能做到这一点而 C/C++ 做不到?关键区别在于 Rust 编译器在编译阶段会做一次非常严格的静态分析,业内叫它借用检查器(Borrow Checker)。它不依赖运行时,也没有垃圾回收线程,纯粹靠编译期的规则约束来保证内存安全。这一点非常重要:这意味着 Rust 能在不引入 GC 性能开销的前提下,实现比 C/C++ 更高的内存安全等级。
我在实际接触 Rust 嵌入式开发时,对这点体会尤其深。嵌入式场景里根本没有多余的资源去跑一个垃圾回收器,C 语言又需要开发者高度自律。Rust 在编译期就把大量内存错误挡在门外,CPU 和内存开销却和手写 C 几乎无异,这让它在 ESP32、STM32 这类资源受限设备上越来越有存在感。后面我也会稍微展开说说结构体与内存布局的关系,那和这方面直接相关。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深入所有权:绑定、移动与 Copy 类型
聊完设计动机,我们得真正上手理解所有权在代码里是怎么表现的。刚学 Rust 的时候,最容易让人蒙圈的不是语法,而是“为什么我把变量传进函数之后,原来的变量就不能用了”。
2.1 栈上数据与 Copy 语义
先看一个最简单的例子:
rust复制let a = 42;
let b = a;
println!("a = {}, b = {}", a, b);
这段代码可以正常编译运行,a 在赋值给 b 之后依然有效。原因在于整数类型实现了 Copy 特征(trait),它在赋值时做的是逐字节拷贝,a 和 b 各自持有一份独立的 42,互不干扰。
Rust 里像整数、浮点数、布尔值、字符这些固定大小的标量类型,都实现了 Copy。它们的值存储在栈上,复制成本极低,所以 Rust 编译器干脆让你“复制”而不是“移动”。这也是为什么这类变量你不用担心赋值之后被“夺走”。
但这里要特别提醒:不要以为所有类型都能这样随意复制。判断某个类型是不是 Copy,你可以查看它的文档或者直接看它有没有实现 Copy trait。普通人最容易忽略的一点是:如果一个类型内部含有堆上分配的数据,它通常是 Clone 而不是 Copy,这两者的语义有本质区别。
2.2 堆上数据与移动语义
把上面的例子换成 String:
rust复制let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // 编译错误:value borrowed here after move
这段代码直接编译不过。你可能会觉得奇怪:“一个字符串赋值而已,怎么原来的变量还不能用了?”要解释清楚这个,就得看 String 在内存里到底是什么样子。
一个 String 在栈上存放的是一个结构体,里面包含三个字段:指向堆内存的指针、字符串长度、容量。真正存储字符数据的缓冲区在堆上。如果让 s1 和 s2 这两个栈上结构体同时指向同一个堆缓冲区,那么当 s1 和 s2 离开作用域时,它们都会尝试释放同一块堆内存,这就是经典的“重复释放”问题。
为了避免这种情况,Rust 选择了移动语义:当 s1 赋值给 s2 时,所有权从 s1 转移到 s2,s1 从此被视为“已失效”。之后如果你再尝试使用 s1,编译器会直接报错。这不是 Rust 在故意跟你作对,而是在编译期就堵死了悬垂指针和重复释放的路径。
用生活化的方式理解:这把钥匙原来在 A 手里,A 把它交给 B 之后,A 的口袋里就没有钥匙了。你不能让 A 再掏一次口袋。真实项目里,这个设计确实会带来一些不便,但适应之后,你的代码里少了一大类只在运行时才会爆炸的 bug。
2.3 函数传参与返回的所有权转移
函数调用也是所有权转移的常见触发点。把变量传给函数,相当于把值的所有权移交给了函数的参数变量:
rust复制fn take_ownership(s: String) {
// 在这里,s 拥有传入字符串的所有权
} // s 离开作用域,String 被释放
fn main() {
let s = String::from("hello");
take_ownership(s);
// 在这里再使用 s 就会报错,因为所有权已经转移
}
函数返回值的道理也类似:返回值会把它持有的所有权交给调用者。如果你既想用函数处理数据、又不想失去所有权,那就要么把值传进去再返回出来,要么用下一节要讲的引用。
2.4 借用检查器在编译期做了什么
借用检查器并不是一个独立运行的工具,而是编译过程中非常核心的一个分析阶段。它会追踪每个变量的生命周期、哪些引用被创建、哪些引用还处于活跃状态,然后验证这一切是否符合所有权规则。
我自己的体验是:刚写 Rust 时,经常觉得借用检查器“莫名其妙”拦下一些看起来没问题的代码。但本质上它是在用一种非常保守的策略,保证在任意时刻都不会存在“对同一块内存的读写冲突”或“对被释放内存的引用”。这种保守确实会带来一些学习曲线,但它换来的是运行时极高的稳定性。
如果你做的是服务端开发,这一点尤其有价值。线上进程打补丁、热更新这类操作,最怕的就是内存状态不对导致崩溃。Rust 的编译期保障不能说完全杜绝此类问题,但确实能帮你在发布前就排除掉大量隐患。
3. 引用与借用:不转移所有权也能用数据
前面提到,如果函数总是需要拿走所有权,代码会非常别扭。所以 Rust 提供了引用机制,让你可以“借用”一个值而不拥有它。借用这个词非常准确:你只是临时拿过来看一下,所有权还在别人手里,看完必须归还。
3.1 不可变引用与实际应用
创建不可变引用的方式是在类型前加 &:
rust复制fn calculate_length(s: &String) -> usize {
s.len()
} // 这里 s 只是借用,不拥有字符串,离开作用域不会释放内存
fn main() {
let s = String::from("hello");
let len = calculate_length(&s);
// 此处 s 依然可用,因为所有权没有转移
println!("Length of '{}' is {}.", s, len);
}
加了一个 &,函数的调用方式就从“把值交出去”变成了“把值借给我看看”。由于只是借用,函数没有权限修改这个值,s 在 main 函数里仍然可以继续使用。这是我个人认为 Rust 里性价比最高的语法特性之一:它让你在传参时不再有任何所有权焦虑。
在实际项目里,函数参数能用 &T 就用 &T。只有当函数确实需要修改数据时,才考虑 &mut T 或者直接拿走所有权。这是 Rust 社区里的一个默认共识,也符合最小权限原则。
3.2 可变引用:同一时间只能有一个
如果想修改变量的值,就必须用可变引用,写法是 &mut:
rust复制fn append_world(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
append_world(&mut s);
println!("{}", s); // 输出 "hello, world"
}
可变引用有一个非常强的限制:同一时刻,对同一个值只能存在一个可变引用。不能同时创建两个 &mut 指向同一个变量,也不能同时存在一个可变引用和任意数量的不可变引用。
为什么要有这个限制?答案就是数据竞争。设想两个线程同时对一块内存做写操作,结果必然不可预测。Rust 干脆把这个可能性拿到编译期消灭,规则简单粗暴:数据在任意时刻要么只有一个写者,要么有多个读者,绝不能读写同时存在。
3.3 悬垂引用:编译器帮你拦下的经典事故
悬垂引用指的是某个引用指向的内存已经被释放了,但引用本身还在被使用。在 C/C++ 里这是极难排查的问题,因为崩溃可能发生在内存被复用之后的任意时刻。
Rust 通过编译期规则直接拒绝悬垂引用:
rust复制fn dangle() -> &String {
let s = String::from("hello");
&s
} // 这里 s 被释放,返回的引用指向无效内存
这段代码无法通过编译。借用检查器会发现,函数返回的引用指向了一个局部变量,而这个局部变量在函数返回时就会被销毁。在 C/C++ 里,编译器往往只会给一个 warning,但在 Rust 里这是一个硬错误。
3.4 引用规则速查
借用规则归纳起来就是如下几条,建议先背下来再去写代码:
- 在任意时刻,要么只能有一个可变引用,要么只能有任意数量的不可变引用。
- 引用必须始终有效,也就是说引用不能比其指向的值活得更久。
- 引用只是借用,不拥有值,因此不会触发内存释放。
我在指导新人时发现,第二点尤其容易踩坑。引用必须始终有效,这个“有效”不仅包括值没有被提前释放,还包括你使用引用的位置不能超出值的作用域。编译器报错信息里经常出现 lifetime 这个词,下一节我会专门展开。
4. 生命周期:让借用检查器放心的“保人”
提到生命周期,很多初学者直接就慌了。但其实你日常写的大多数代码都不需要手动标注生命周期,编译器能自己推断出来。只有在某些函数签名中存在多个引用参数、编译器无法确定它们的存活关系时,才需要你显式标注。
这种标注并不是运行时操作,不会影响性能,只是在给编译器提供信息,相当于你给借用检查器当“保人”,告诉它:“这个引用和那个参数之间有明确的关系,你放心检查。”
4.1 生命周期标注的基本语法
生命周期标注的语法比较独特:以撇号开头,通常用一个简短的小写字母表示,例如 'a:
rust复制fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
这段代码的意思是:函数 longest 接收两个字符串切片引用,返回值也是一个字符串切片引用,并且这三个引用的生命周期都和同一个参数 'a 有关。换句话说,函数返回的引用,存活时间不会超过 x 和 y 中较短的那个。
为什么要这样写?因为编译器的静态分析能力有限,它无法凭空判断一个函数的返回值到底借用的是第一个参数还是第二个参数,更无法判断这个返回引用在调用后还能活多久。你给出 'a 的约束,编译器才能证明返回值是安全的。
4.2 结构体中的生命周期标注
生命周期不只在函数中出现,结构体如果要持有引用,也必须标注生命周期。这一点是新手最容易忽略的:结构体本身没有“生命周期”这个概念,需要你自己显式声明泛型生命周期参数,并且每个引用字段都要标注。
rust复制struct Excerpt<'a> {
content: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let excerpt = Excerpt {
content: first_sentence,
};
// excerpt 不能比 novel 活得更久
}
这里的意思很明确:Excerpt 结构体实例不能比它引用的字符串活得更久。只要这个前提成立,编译器就放心了。如果不写 'a,这段代码根本无法通过编译。
我在写 Rust 嵌入式固件时经常需要这类结构体,比如 DMA 缓冲区的切片引用、外设寄存器的内存映射引用,这时候生命周期标注就成了必不可少的安全保障。
4.3 生命周期省略规则:大部分时候你不需要手动标注
看到这里你可能担心:那我岂不是每写一个引用都要考虑生命周期?其实不然。Rust 有一条“生命周期省略规则”,能在大多数场景下自动推断生命周期的关系。
基础规则主要有三条:
- 每个引用参数都有自己的生命周期参数。
- 如果只有一个引用参数,那么返回值的生命周期就取这个参数的生命周期。
- 如果有多个引用参数,但其中一个是
&self或&mut self,那么返回值的生命周期取自self。
基于这些规则,很多函数签名其实可以省略生命周期标注。比如:
rust复制fn first_word(s: &str) -> &str {
// ...
}
编译器会自动理解为:
rust复制fn first_word<'a>(s: &'a str) -> &'a str
只有当规则无法覆盖的复杂场景,比如多个引用参数且返回值与参数关系不明时,编译器才会强制你手动标注。初学时不用怕,多写几次自然就熟练了。
4.4 生命周期与运行时开销的关系
一个非常常见的误解是,生命周期标注会影响程序运行时的表现。实际上完全不会。生命周期标注和泛型参数一样,编译完成后就被完全擦除了。它只存在于编译期,是给编译器做静态分析用的,不产生任何运行时指令,也不占用内存。
我经常用一句话给团队里的新人解释:生命周期借用检查器的“审阅档案”,只存在编译那几分钟里,和线上运行没有任何关系。
5. 结构体:从零搭建自己的数据类型
理解了所有权、借用、生命周期这些基础概念之后,我们可以进入 Rust 的类型构建部分了。结构体(struct)是 Rust 中自定义数据类型的第一选择,也是你组织数据的主要方式。
如果之前写过 C/C++,对结构体这个概念一定不陌生:把几个相关的字段打包成一个整体。Rust 的结构体在语法上更严谨,而且它和所有权系统的结合非常紧密,这一点在后面会看到。
5.1 定义与实例化
结构体的定义方式很直接:
rust复制struct Video {
title: String,
duration_secs: u32,
resolution: (u32, u32),
is_public: bool,
}
实例化时需要给所有字段赋值,并且字段名不能省略:
rust复制let video = Video {
title: String::from("Rust 入门"),
duration_secs: 1800,
resolution: (1920, 1080),
is_public: true,
};
如果要从已有实例创建新实例,可以使用结构体更新语法 ..:
rust复制let video2 = Video {
title: String::from("Rust 进阶"),
..video
};
这里有一个和所有权相关的细节:如果 video 的 title 是 String(非 Copy 类型),那么 video2 创建后,video.title 的所有权会被移动到 video2 中,之后不能再通过 video.title 访问视频标题。其他标量字段因为是 Copy 类型,会正常复制,不会受影响。
5.2 元组结构体与单元结构体
除了最常用的命名结构体,Rust 还支持元组结构体(Tuple Struct)和单元结构体(Unit Struct)。
元组结构体的字段没有名字,只有类型和顺序,适合包裹一些语义比较单一的值:
rust复制struct Point2D(pub f32, pub f32);
struct Color(pub u8, pub u8, pub u8);
let p = Point2D(0.0, 3.14);
let c = Color(255, 0, 128);
元组结构体特别适合用来做“新型类型”,比如在同一个项目中同时有 UserId 和 UserName,用元组结构体包裹后,编译期就能防止你误把 userId 当 userName 用。
单元结构体没有字段,更像是一个“标记”,常用来配合 trait 实现某种类型级别的状态标记。我经常在写嵌入式代码时用它来代表某个外设实例的类型,配合类型系统实现状态机约束。
5.3 方法:impl 块与 self / &self / &mut self
结构体只能定义数据,不能直接定义行为。行为要通过 impl 块来添加方法:
rust复制impl Video {
fn display_info(&self) {
println!(
"'{}' ({}x{}), duration: {}s",
self.title, self.resolution.0, self.resolution.1, self.duration_secs
);
}
fn set_public(&mut self, public: bool) {
self.is_public = public;
}
fn create_draft(title: String) -> Self {
Video {
title,
duration_secs: 0,
resolution: (0, 0),
is_public: false,
}
}
}
这里面的 self 参数有三种常见形态,代表了方法对结构体数据的不同访问方式:
self:拿走所有权,方法结束时结构体实例会被释放,适合转换或销毁场景。&self:不可变借用,只能读取字段,最常见的形式。&mut self:可变借用,可以修改字段。
这个设计非常符合 Rust 的所有权哲学。你调用一个方法时的传参方式,直接决定了这个方法有没有权限修改对象。C++ 成员函数的 const 修饰符作用类似,但 Rust 的表达更统一、更严格。
5.4 结构体中的引用字段与生命周期
结构体字段如果要用引用类型,必须显式标注生命周期。前面 4.2 已经见过一个例子,这里再补充一点实际建议:除非有必要,尽量让结构体直接持有数据(所有权),而不要持有引用。
原因很简单:持有引用会让整个结构体的生命周期受制于被引用数据,使用起来非常不自由,也会让代码满篇都是生命周期标注。只有在某些特殊场景下,比如你想避免拷贝大型数据、或者要实现零拷贝解析时,才值得用引用字段。
一个折中方案是使用 String 或 Vec 等拥有所有权的类型,确实需要共享时再借助 Rc 或 Arc。这个思路可能和很多人的直觉相反:Rust 里最常见的做法不是到处用引用,而是尽量拥有数据,需要共享时才引入智能指针。
5.5 结构体的内存布局、对齐与 repr(C)
结构体内的字段在内存中并不是紧挨着排列的,而是存在对齐(alignment)概念。Rust 编译器在默认情况下会选择让每个字段按自身对齐方式排列,这可能会在字段之间插入填充字节(padding)。
要具体看结构体的内存布局,可以用 std::mem::size_of 和 std::mem::align_of 查看:
rust复制use std::mem;
struct Foo {
a: u8,
b: u64,
c: u8,
}
fn main() {
println!("size of Foo: {}", mem::size_of::<Foo>());
println!("align of Foo: {}", mem::align_of::<Foo>());
}
如果按直观理解,u8 占 1 字节,u64 占 8 字节,另一个 u8 占 1 字节,总共应该是 10 字节。但由于对齐原因,实际大小很可能是 24 字节(u64 对齐到 8),中间存在大量填充字节。
这在 C/C++ 里也很常见,但 Rust 默认布局并不保证字段顺序,编译器甚至可能为了减少填充重新排列字段。如果你需要和 C 语言交互(比如写动态库给 C 调用),就必须用 #[repr(C)] 让结构体按照 C 语言的布局规则排列:
rust复制#[repr(C)]
struct NetworkHeader {
version: u8,
type_: u8,
length: u16,
payload: [u8; 8],
}
如果你在做嵌入式开发或者网络协议解析,这类细节会直接影响你的数据包能不能和硬件或其他语言正常交互。具体到某些平台,比如 ESP32 的寄存器配置结构体,如果不加 #[repr(C)] 或者布局不对,写进去的寄存器值很可能完全不是你期望的那个。
6. trait:把“行为”抽象成接口
结构体关心的是“数据是什么”,而 trait 关心的是“这个类型能做什么”。如果你用过 Java 的接口(interface)、C++ 的纯虚类、或者 Go 的 interface,会觉得 Rust 的 trait 和它们属于同一类概念,但 trait 要灵活得多。
trait 的核心作用是定义一组方法签名,然后让不同类型去实现这些方法,从而在类型之间建立共同的行为约定。任何类型都可以实现 trait,这就为泛型编程和多态提供了基础。
6.1 trait 的定义与实现
定义 trait 的语法很简洁:
rust复制trait Description {
fn describe(&self) -> String;
}
为类型实现 trait 时,用 impl ... for ... 的格式:
rust复制impl Description for Video {
fn describe(&self) -> String {
format!("{} ({}s)", self.title, self.duration_secs)
}
}
struct User {
name: String,
age: u8,
}
impl Description for User {
fn describe(&self) -> String {
format!("{} is {} years old", self.name, self.age)
}
}
这就像给不同的类型都安装了一个“描述自己”的能力接口,但每种类型的实现细节完全不同。调用时不需要关心具体是哪种类型,只需要知道它们都实现了 Description trait。
6.2 默认方法:减少重复代码
trait 里可以给方法提供默认实现,实现者可以选择覆盖,也可以直接用默认版本:
rust复制trait Greeter {
fn name(&self) -> &str;
fn greet(&self) -> String {
format!("Hello, {}!", self.name())
}
}
struct Dog {
dog_name: String,
}
impl Greeter for Dog {
fn name(&self) -> &str {
&self.dog_name
}
// greet 使用默认实现
}
fn main() {
let d = Dog { dog_name: String::from("Rex") };
println!("{}", d.greet()); // 输出 "Hello, Rex!"
}
默认方法能让 trait 在演进时保持向后兼容。想象你已经发布了一个 trait,别人已经为他们的类型实现了它,这时候你想新增一个方法。如果这个方法没有默认实现,所有下游使用者都会收到编译错误;有了默认实现,大家什么也不用改,代码继续跑。
6.3 trait 作为参数:impl Trait、泛型约束与 dyn Trait
trait 最常见的用途之一,是作为函数参数来约束类型。Rust 提供了三种主要方式。
第一种是 impl Trait 语法,适合参数只需要用一次的场景:
rust复制fn print_description(desc: impl Description) {
println!("{}", desc.describe());
}
第二种是泛型约束,适合需要多个参数属于同一类型或者返回值类型需要与参数保持一致的场景:
rust复制fn print_description<T: Description>(desc: T) {
println!("{}", desc.describe());
}
第三种是 trait 对象 dyn Trait,适合需要把不同类型放进同一个 Vec 或结构体中的场景:
rust复制fn print_all(items: Vec<Box<dyn Description>>) {
for item in items {
println!("{}", item.describe());
}
}
dyn Trait 涉及动态分发,会有一定的运行时开销,但换来的是类型擦除带来的灵活性。什么时候用 impl Trait 什么时候用 dyn Trait,核心取舍在于:你更在乎性能还是更在乎灵活。大多数服务端业务代码里这两者的性能差异其实可以忽略,建议优先选可读性更好的方式。
6.4 常用标准库 trait:Display、Drop、Clone、Default
标准库自带了一大批非常有用的 trait,新手至少应该知道下面这几个:
Display:定义类型如何以用户可读的文本形式输出,配合println!("{}", x)使用。Debug:定义调试输出格式,配合println!("{:?}", x)使用,推荐给所有结构体派生实现。Drop:定义值离开作用域时的清理逻辑,类似于 C++ 的析构函数。Clone:定义类型如何显式复制自己,返回独立的副本。Default:定义类型的默认值,常配合..Default::default()使用。
给结构体派生的写法非常常见:
rust复制#[derive(Debug, Clone, Default)]
struct Video {
title: String,
duration_secs: u32,
resolution: (u32, u32),
is_public: bool,
}
derive 宏会自动生成对应 trait 的实现代码,省去大量手写样板。如果你编写的类型字段都实现了某个 trait,那这个类型往往也可以通过 derive 自动继承同样的实现。不过要注意,Display 通常需要手写,因为编译器无法猜出你想要的展示格式。
6.5 为自定义结构体实现 trait 的完整示例
我们把前面讲的内容拼在一起,看一个稍微复杂点的示例:
rust复制use std::fmt;
#[derive(Debug, Clone)]
struct Video {
title: String,
duration_secs: u32,
}
impl fmt::Display for Video {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({} min)", self.title, self.duration_secs / 60)
}
}
trait Summary {
fn summary(&self) -> String;
}
impl Summary for Video {
fn summary(&self) -> String {
format!("{} - {}", self.title, self.duration_secs)
}
}
fn print_summary<T: Summary>(item: T) {
println!("{}", item.summary());
}
fn main() {
let v = Video {
title: String::from("Rust Trait 实战"),
duration_secs: 3600,
};
println!("{}", v);
print_summary(v);
}
通过这个例子,你可以看到结构体、trait、泛型约束如何有机组合在一起。Video 既是数据结构,也能以多种方式被展示和操作,而且这一切都是在编译期完成的,运行时不额外增加负担。
7. 组合实战:一个综合示例练通所有概念
有句话叫“纸上得来终觉浅”,Rust 尤其如此。前六节的每一个概念单独看都不难,难的是把它们组合到同一个文件里,还能通过编译、正确运行。这一节我们设计一个稍微综合一点的例子,把所有提到的概念串起来。
7.1 场景设计
假设我们要管理一个视频库,每个视频包含标题、时长、作者等信息;作者信息单独用一个结构体表示;我们要提供按作者过滤、计算总时长、格式化输出这些功能。
这里刻意加入了一些需要所有权和引用配合的场景,方便一起练习。
7.2 完整代码与逐段解释
rust复制use std::fmt;
#[derive(Debug, Clone)]
struct Author {
name: String,
country: String,
}
impl Author {
fn new(name: &str, country: &str) -> Self {
Self {
name: name.to_string(),
country: country.to_string(),
}
}
}
#[derive(Debug, Clone)]
struct Video {
title: String,
duration_secs: u32,
author: Author,
}
impl Video {
fn new(title: &str, duration_secs: u32, author: Author) -> Self {
Self {
title: title.to_string(),
duration_secs,
author,
}
}
fn duration_minutes(&self) -> u32 {
self.duration_secs / 60
}
}
impl fmt::Display for Video {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"《{}》 by {} ({} min)",
self.title,
self.author.name,
self.duration_minutes()
)
}
}
trait Categorizable {
fn category(&self) -> &str;
}
impl Categorizable for Video {
fn category(&self) -> &str {
if self.duration_secs < 600 {
"short"
} else {
"long"
}
}
}
struct VideoLibrary {
videos: Vec<Video>,
}
impl VideoLibrary {
fn new() -> Self {
Self { videos: Vec::new() }
}
fn add_video(&mut self, video: Video) {
self.videos.push(video);
}
fn total_duration(&self) -> u32 {
self.videos.iter().map(|v| v.duration_secs).sum()
}
fn videos_by_author<'a>(&'a self, author_name: &'a str) -> Vec<&'a Video> {
self.videos
.iter()
.filter(|v| v.author.name == author_name)
.collect()
}
}
fn main() {
let author1 = Author::new("Alice", "China");
let author2 = Author::new("Bob", "USA");
let mut library = VideoLibrary::new();
library.add_video(Video::new("Rust Quickstart", 900, author1.clone()));
library.add_video(Video::new("Embedded Rust on ESP32", 1500, author1.clone()));
library.add_video(Video::new("C++ vs Rust", 480, author2));
println!("Total duration: {} sec", library.total_duration());
let alice_videos = library.videos_by_author("Alice");
for v in alice_videos {
println!("{} [{}]", v, v.category());
}
}
这个示例里的关键点不少,我挑几个重点说说。
第一,videos_by_author 这个方法的签名里有生命周期标注。因为我返回的是一个 Vec<&Video>,引用的数据来自 self.videos,所以返回生命周期必须和 &self 保持一致。这种场景下,省略规则正好能帮我们省去手写 'a 的麻烦,但如果你想挑战自己,也可以把生命周期显式写出来,理解会更深入。
第二,Author 结构体实现了 Clone,所以我在添加多个视频复用一个作者时直接调用 author1.clone()。如果不 clone,author1 的所有权在第一次 add_video 时就被移动进 library 里了,后面再用就会报错。这是新手非常容易踩的坑:想复用同一个值,又不想让它被移走,最直接的办法就是克隆一份进去。
第三,library.videos_by_author("Alice") 返回的是借用类型的 Vec,而不是持有所有权的 Vec<Video>。这是因为我们只是要读取数据,不想把视频从库里搬走。借用在这里完全够用,而且不破坏 library 的完整性。接着在循环里同时使用 v 和 v.category(),一个用于 Display 格式化输出,一个用于 trait 方法调用,两者互不干扰,因为都是不可变借用。
7.3 实战中如何规划数据结构与 trait 边界
写完这个示例,我想特别聊一下设计层面的体会。Rust 里定义一个结构体很容易,难的是想清楚:哪些数据应该被组合在同一个结构体里,哪些行为应该抽象成 trait,哪些地方用拥有所有权的类型,哪些地方用引用。
我的经验是:先画清楚数据的“归属图”。谁拥有什么数据、谁在什么阶段需要借用什么数据,想清楚之后再动笔。如果发现某个类型需要到处借用,很可能你还没有选对所有权模型。重新设计一种让数据集中归属的方案,代码会舒服很多。
trait 的边界也同理。当你发现多个类型都有相同的方法名时,才值得考虑抽象成 trait。单独为一个类型定义 trait 反而浪费。保持小步前进,代码自然会在你不断重构的过程中变得越来越清晰。
8. 常见问题与排查技巧实录
最后这部分,我把自己和同事们在实际编码中踩过的一些高频问题整理出来。这些问题本身不难解决,但如果你不知道原因,很容易在编译器错误信息面前一头雾水。
8.1 报错:cannot move out of borrowed content
这是新手最常见的问题之一。出现场景往往是:你通过引用访问一个字段,然后想把这个字段返回给外部使用:
rust复制struct Video {
title: String,
}
impl Video {
fn get_title(&self) -> String {
self.title // 编译错误:cannot move out of borrowed content
}
}
self 是 &self,通过它只能借用 self.title,不能把它 move 出去。修复方式有两种:如果调用方确实需要拥有这个字符串,就克隆一份返回:
rust复制fn get_title(&self) -> String {
self.title.clone()
}
如果调用方只需要读取,就返回引用:
rust复制fn get_title(&self) -> &str {
&self.title
}
我更推荐第二种,除非你真的需要一份独立的拷贝。返回引用不产生分配和拷贝,性能更好。
8.2 报错:borrowed value does not live long enough
这个错误通常和生命周期有关。最常见的场景是在一个函数里创建了一个局部变量,然后试图返回指向它的引用。前面讲悬垂引用时其实已经见过:函数返回一个指向局部变量的引用是不可能被允许的,因为局部变量在函数返回时就被销毁了。
如果确实需要返回一个“新创建的值”,正确做法是直接返回这个值本身(移动所有权),而不是返回它的引用:
rust复制fn make_video() -> Video {
Video {
title: String::from("New"),
duration_secs: 0,
}
}
调用方会获得 Video 的所有权,完全不存在悬垂问题。
还有一种情况是:你从函数中返回了一个结构体方法里得到的字段引用,但这个字段的所有权实际上是局部变量持有的,生命周期不够长。这时候要回到 8.1 的讨论,要么返回克隆的值,要么改造结构,让数据的所有权归属更合理。
8.3 报错:cannot borrow as mutable because it is also borrowed as immutable
这个错误的出现说明你违反了借用规则:同时持有了不可变引用和可变引用。我经常在同时遍历集合并修改内容时遇到:
rust复制let mut items = vec![1, 2, 3];
let first = &items[0];
items.push(4); // 编译错误:push 需要可变借用,但 first 还是不可变借用
println!("{}", first);
如果在修改前就已经有一个不可变引用存在,编译器就不会让你继续修改。解决思路也很直接:调整代码顺序,让不可变引用在可变操作之前结束使用。只要你把 println! 提到 push 前面,这个问题就没了。
从这个例子你能感受到,借用检查器实际上是强迫你写出“时间段更清楚”的代码:一段数据要么被读取,要么被修改,绝不重叠。
8.4 方法调用顺序中 &self 与 &mut self 的冲突
如果你在一个循环里先调用了一个 &self 方法,又调用了另一个 &mut self 方法,也会踩到类似的雷。因为 &self 方法的调用会把不可变借用“活着”保留到它被使用的最后位置,而 &mut self 方法需要独占借用,二者时间上重叠就会冲突。
解决方式同样是调整顺序,把不可变借用需要的数据先取出来,再执行可变操作。如果实在无法避免,可以考虑使用内部可变性(比如 RefCell),但我不建议新手一开始就碰这个概念,先学会用调整代码结构来解决问题,会积累更多对借用规则的理解。
8.5 常见错误对照表
| 错误类型 | 典型报错关键词 | 根本原因 | 推荐处理方式 |
|---|---|---|---|
| 移动后使用 | value borrowed here after move | 非 Copy 类型所有权已转移,原变量失效 | 使用引用 &s 或 clone() |
| 借用内容移动 | cannot move out of borrowed content | 尝试把借用字段的所有权转移出去 | 返回引用或克隆值 |
| 悬垂引用 | does not live long enough | 返回了指向局部变量的引用 | 返回值本身,而不是值的引用 |
| 借用冲突 | cannot borrow as mutable because it is also borrowed as immutable | 同时持有不可变引用和可变引用 | 调整作用域,先结束不可变借用 |
| 生命周期不匹配 | lifetime may not live long enough | 返回引用与参数的生命周期关系不明确 | 显式添加生命周期标注 |
把这几个模式记住,至少能解决你日常开发中八成以上的编译报错。剩下两成,基本就是这些模式的组合变形,慢慢排查总能定位到。
我个人的体会是:Rust 的编译错误信息在主流语言里算非常贴心的,它会明确指出问题出在哪一行、涉及哪些借用、怎么改。很多报错下方甚至直接附带建议。遇到搞不懂的错误,先通读报错全文,再回看代码的数据流,通常很快能想明白问题在哪。
学 Rust 这件事,最难的不是语法,而是思维方式的转变。你过去习惯的“先写逻辑,再想着怎么修 bug”的顺序,在 Rust 里可能需要倒过来:先在脑子里走一遍数据归属和借用的路径,再落笔写代码。这个过程一开始很别扭,但坚持一段时间后,你会发现自己写出来的代码结构反而更清晰了。那种交付到线上后不再为内存崩溃提心吊胆的踏实感,就是 Rust 带给你的最大回报。
