
共享引用 &T
思考必须先于行动——在 Rust 中,访问一个值的方式,决定了你能对它做什么、不能做什么。如果说所有权回答了"谁拥有这个值",那么引用要回答的则是"谁能暂时借用这个值"。当我们不希望值的拥有者移交控制权,又需要让其他代码访问这个值时,共享引用(shared reference) 就是 Rust 给出的第一个答案。
从所有权到借用
回顾第 6 篇文章中的核心结论:值的所有权在任意时刻只能属于一个变量。如果每次需要读取一个值,都必须把所有权转移出去,那么读完之后还得再转移回来——这不仅是繁琐的问题,更是逻辑上的灾难。考虑一个最简单的场景:
fn get_length(s: String) -> usize {
s.len() // 读取 s 的长度
}
fn main() {
let s = String::from("hello");
let len = get_length(s); // 所有权被转移进函数
println!("{}", len);
// println!("{}", s); // ❌ 编译错误:s 已不可用
}
即使只是读取 s 的长度,get_length 也迫使我们把 s 的所有权交出去。一旦函数返回,s 在 main 中就再也无法使用了。这显然不是我们想要的——读取一个值,不应该等于摧毁这个值。解决方案就是 借用(borrowing):允许你临时访问一个值,但不改变它的所有者。
创建引用:&T 语法
创建一个共享引用,只需在变量名前加上 & 符号:
fn get_length(s: &String) -> usize {
s.len() // 通过引用读取值,不获取所有权
}
fn main() {
let s = String::from("hello");
let len = get_length(&s); // 创建 &String,借用 s
let s_again = &s; // 还可以再创建第二个引用
println!("{}", s); // ✅ s 仍然可用
println!("{}", len);
}
&T 读作"对 T 的共享引用"。在类型层面,&String 和 String 是两种完全不同的类型:前者是对后者的引用,后者是值的本体。引用的核心特征是:
- 它不拥有所指向的值,因此不需要在作用域结束时负责清理它
- 它是一个Copy类型——创建多少个引用都可以,复制引用本身是廉价的
- 它指向的值位于内存中的某个地址,你可以把它理解为一个"只读窗口"
let x = 42;
let ref1 = &x; // 第一次借用
let ref2 = &x; // 第二次借用——完全合法
let ref3 = ref1; // 复制引用本身——合法
这里最重要的一句话是:共享引用是只读的。通过 &T 你能做的只有"看",不能"改"。
只读访问:你能做什么
一个 &T 给了你哪些能力?具体而言:
- 读取字段或调用方法:只要该操作只需要读取权
- 复制值:如果
T实现了Copytrait(如整数、布尔、浮点数) - 创建更多的只读引用:引用可以继续被引用
struct Point {
x: f64,
y: f64,
}
fn distance_from_origin(p: &Point) -> f64 {
// 读取字段是允许的
(p.x * p.x + p.y * p.y).sqrt()
}
fn main() {
let origin = Point { x: 3.0, y: 4.0 };
let d = distance_from_origin(&origin);
// origin 仍然属于 main,我们只是借看了一下
}
但以下操作是被禁止的:
fn mutate(p: &Point) {
p.x = 10.0; // ❌ 编译错误:&Point 中字段不可变
}
这一限制并非 Rust 的"保守主义",而是保证正确性的必要条件。设想一下:如果多个地方同时持有一个共享引用,而其中某个地方修改了值,那么其他引用读取到的数据就变了——这在多线程环境下就是 数据竞争(data race)。Rust 在编译期直接堵死了这条路径:共享意味着只读,想写就必须持有可变引用(那是下一小节的内容)。
多个共享引用共存:借用检查,而非引用计数
以借款类比:如果一个人把书借给你,你和另外三个人同时各拿了一本副本阅读——只要没有人动笔在上面写字,大家就都能安心阅读。共享引用就是这个场景:N 个读者同时读一本书,互不干扰。
fn main() {
let data = vec![1, 2, 3, 4, 5];
let r1 = &data; // 读者 1
let r2 = &data; // 读者 2
let r3 = &data[..]; // 读者 3,借用整个切片
println!("第一个元素: {}", r1[0]);
println!("第三个元素: {}", r2[2]);
println!("整个向量: {:?}", r3);
}
这段代码能编译且能运行,因为这些引用都只是临时访问路径:创建 &T 不会增加 Rc/Arc 那样的运行时计数,普通引用通常也没有克隆、原子操作或堆分配成本。能否共存由编译器依据借用范围静态判断。
更精确地说,&T 禁止通过这条共享引用路径直接修改值;若类型内部使用 Cell、RefCell、原子类型或锁,仍可能在共享引用后发生内部可变性。因此规则不是“内存永远不变”,而是“不能通过普通 &T 获得未受控的可变访问”。内部可变性将在智能指针一篇中补齐。
这背后的设计哲学是"共享不可变"的互斥原则——要么共享,要么可变,但永远不能同时进行。这一点正式的名称是借用规则,下一小节将讨论它的另一半。
悬垂引用的第一道防线
引用还有一个隐藏的安全属性:编译器保证引用永远不会指向已被释放的内存。如果一个引用指向的值在其使用之前就被销毁了,编译器会直接报错:
fn dangling() -> &String {
let s = String::from("temp");
&s // ❌ 编译错误:s 在这里被释放,返回的引用悬空了
}
这里的核心机制是:s 在函数结束时会离开作用域并被释放,而返回的 &String 却指向这块已释放的内存——这就是悬垂引用(dangling reference)。Rust 在编译期就拦截了这个错误,根本不会让它运行到崩溃那一刻。当然,这个例子涉及到了生命周期(lifetime)的概念——生命周期的细节将在后续文章中展开,这里只需理解 Rust 在编译期就建立了"引用的存活时间不能超过被引用值"这一规则。
小结
共享引用 &T 是 Rust 借用系统的第一根支柱:它允许任意数量的代码同时读取一个值,而不转移所有权。语法上只需在类型前加 &,语义上是只读的,编译期保证不能修改、不能悬垂。但当我们需要修改一个值而又不转移所有权时,&T 就无能为力了——这正是下一个要讨论的角色:可变引用 &mut T。它会带来更严格的限制,也因此带来了更强的保证。
可变引用 &mut T
共享引用让多个读者可以同时查看一个值,但它有一个天然的边界:只读。当代码需要修改被借用的值时,共享引用就无能为力了。Rust 为此提供了第二种引用形式——可变引用(mutable reference),写作 &mut T。如果说共享引用是"多人共读一本书",那么可变引用就是"一个人握着笔在书上改写"——为了保证改写过程不产生混乱,这笔在同一时刻只能握在一个人手里。
从只读到可写:&mut T 的创建
创建可变引用的语法与共享引用几乎对称,区别仅在于 mut 关键字:
fn main() {
let mut count = 0; // 注意:变量本身必须声明为 mut
let counter = &mut count; // 创建对 count 的可变引用
*counter += 1; // 通过解引用(*)修改原值
println!("{}", count); // 输出 1,count 的值确实被改变了
}
这里有一个关键细节值得停下来审视:变量声明必须显式标注 mut。在 Rust 中,let count = 0 声明的是一个不可变绑定,编译器不允许对它创建 &mut 引用。这个限制并非繁琐,而是所有权体系的自然延伸——一个不可变绑定的值,本就不该被修改。
通过 *counter += 1 这行代码,我们完成了对值的间接修改。这里的 * 是解引用运算符,它让代码穿透引用这层"外壳",直接触及底层的值。对比共享引用场景下我们只使用 & 来访问字段或方法,可变引用则经常伴随着 * 出现在赋值运算符的左侧。这两种操作模式的分野,恰好对应了"读"与"写"的本质区别。
独占可变:为什么同一时刻只能有一个
可变引用最核心的规则可以浓缩为一句话:同一时刻,一个值最多只能有一个可变引用。尝试违反这条规则,编译器会直接拒绝:
fn main() {
let mut data = vec![1, 2, 3];
let a = &mut data;
let b = &mut data; // 错误:无法同时借用 data 为可变
a.push(4);
}
这段代码无法通过编译,报错信息会明确指出"cannot borrow data as mutable more than once"。为什么 Rust 要如此严苛?回到"思考必须先于行动"的原则——如果允许两个可变引用同时存在,那么 a 和 b 各自都认为自己拥有对数据的完全控制权,它们交替修改同一个值,最终的结果取决于执行顺序,而执行顺序在多线程环境下是不可预测的。这正是数据竞争(data race) 的根源。
数据竞争导致的后果远比"结果不确定"严重:它可能引发内存安全问题,比如两个线程同时读写同一个内存地址,导致读到一半的数据被覆盖。C 和 C++ 中大量的隐蔽 bug 都源于此。Rust 的选择非常干脆——在编译期就消灭这种可能性。既然无法在运行时追踪谁在什么时候修改数据,那就在类型系统层面规定:可变引用是独占的。
这条规则还引出一个有趣的结果:共享引用与可变引用不能同时存在。如果一个值已经被共享引用"借出",那么此刻就不能再创建可变引用;反之亦然。原因很直观——共享引用保证了"只读",而可变引用意味着"写入",两者并存时,读者可能在写入的间隙读到中间状态。
通过引用修改值:作用域与释放
可变引用的"独占"特性并不意味着值永远无法被访问。当可变引用的作用域(scope) 结束时,借用关系自动解除,值的所有权回归变量本身,后续代码又可以自由访问它了:
fn main() {
let mut message = String::from("hello");
{
let m = &mut message;
m.push_str(", world"); // 在作用域内修改
} // m 的作用域结束,借用释放
println!("{}", message); // 输出 "hello, world"——所有权回来了
}
这段代码展示了可变引用的一个常见使用模式:将修改操作限制在一个代码块内。这样做不仅符合"独占"的规则,也让代码的意图更加清晰——读者一看便知这段作用域内发生了写操作。作用域结束后,message 重新成为完全可用的值,可以被打印、被继续修改、甚至被转移所有权。
值得注意的是,作用域的结束点是最后一次使用引用的位置之后,而非 } 所在的那一行。这个规则被称作 NLL(Non-Lexical Lifetimes,非词法生命周期),它的存在让借用检查变得更加智能。举例来说,最后一行 println!("{}", message) 使用了 message 本身而非引用 m,编译器知道 m 已经不再被使用,因此在上一条语句结束后就"提前"释放了借用。这意味着我们无需为了满足借用检查而刻意调整代码结构,Rust 已经尽力在提供安全性的同时保持灵活性。
借用规则的保护还延伸到另一个常见陷阱——悬垂引用(dangling reference)。考虑下面的代码:
fn main() {
let r;
{
let x = 5;
r = &x; // 错误:x 的生命周期太短
}
// 这里 r 指向的内存已被释放
}
Rust 的编译器在编译期就能捕捉到这个错误:x 在内部作用域结束后被销毁,而 r 却仍然引用着它。如果这段代码被放行,后续任何通过 r 的读取都是在访问已释放的内存——这在其他语言中是典型的"释放后使用"(use-after-free)漏洞。Rust 通过借用检查器(borrow checker) 在编译期就拒绝了这段代码,从根源上杜绝了悬垂引用。
至此,Rust 的安全保证逐渐清晰:所有权确保每个值有明确的主人,借用规则确保任何时刻对值的访问要么是"多个只读"、要么是"单个可写",而生命周期检查确保引用永远不超出被引用值的存活范围。这三者层层嵌套,构成了 Rust 内存安全的核心支柱。
有了 &T 和 &mut T 这两个工具,我们便可以在不转移所有权的前提下自由地读写值了。但到目前为止,引用还只是孤立地出现在函数或代码块中。一旦我们进入结构体和枚举的世界,引用将扮演更加关键的角色——结构体字段能否持有引用?枚举的变体如何借用外部数据?这些正是下一节要回答的问题。
借用规则与编译期检查
前两节分别介绍了共享引用与可变引用的创建和使用方式:&T 允许多个读者同时存在,&mut T 要求同一时刻只有一个写者。但这两条规则并非孤立的设计选择——它们共同构成了 Rust 借用系统的一个底层机制:借用检查器(borrow checker)。本节将揭示这个机制如何在编译期工作,以及它究竟拦截了什么。
借用检查:编译期的一个独立阶段
Rust 编译器对代码的检查并非一次完成。在类型检查通过之后、生成机器码之前,有一个专门的阶段叫 借用检查(borrow checking)。这个阶段不关心值的类型是否匹配、函数调用是否正确——这些已经由类型系统处理完毕。它只关心一件事:程序中的每一个借用是否违反了借用规则。
借用检查器的工作方式可以类比为一场演出的后台管理:每个演员(变量)在上台前必须签到(声明所有权),需要道具(值)时要登记借用,且必须遵守"道具室同一时间只能借出"的规则。管理员的职责不是判断演员演得好不好,而是确保每一件道具在任何时刻的借出状态都是合规的。
实现层面,现代 rustc 会在 MIR 上进行区域推断并检查 place 的借用、移动和使用;“画一张借用图”可以作为直觉,但不是应依赖的正式编译器接口。对于普通引用,最核心的入口规则是:
- 共享引用可以同时存在任意多个,但共享引用存在期间,值不能被修改;
- 可变引用同一时刻最多存在一个,且可变引用存在期间,值不能被读取。
如果安全代码违反这些规则,编译器会拒绝编译。更准确的保证是:Safe Rust 不能构造悬垂引用或造成数据竞争;这不等于“所有引用相关问题都不存在”。索引仍可能 panic,RefCell 会把部分借用检查推迟到运行时,锁可能死锁,而 unsafe 代码必须自行维护引用的有效性、对齐与别名不变量。
悬垂引用:在编译期被消灭的指针
悬垂引用指的是引用指向的内存已经被释放,但引用本身仍然存在。在 C 或 C++ 中,这类 bug 往往表现为间歇性的崩溃或数据损坏,且极难定位。Rust 的借用检查器从根源上杜绝了这种可能性。
考虑下面的代码——它试图返回一个指向局部变量的引用:
fn create_reference() -> &i32 {
let x = 42;
&x // 错误:x 在函数返回时被销毁,返回的引用将成为悬垂引用
}
当编译器检查这段代码时,它发现 &x 的创建点(在 create_reference 函数体内)晚于 x 的销毁点(函数返回时 x 离开作用域)。引用 &x 的生命周期比 x 本身的存活时间更长,这就是悬垂。编译器直接拒绝编译,并提示:
error[E0106]: missing lifetime specifier
--> src/main.rs:1:27
|
1 | fn create_reference() -> &i32 {
| ^ expected named lifetime parameter
借用检查器确保了一条不变量:一个引用的存活时间不可能超过它所指值的存活时间。 在这个例子中,编译器甚至不需要复杂的分析——x 是函数内的局部变量,它的析构发生在函数返回时,而返回的引用要求在函数返回后仍然有效,这构成了最直接、最显然的悬垂。更隐蔽的情况——比如引用所指向值的所有者已经离开作用域,但引用被存储在更外层的数据结构中——同样会被借用检查器识别并报告。
注意,这种现象在 C 语言中是完全合法的(虽然是不安全的):
int* create_reference(void) {
int x = 42;
return &x; // 编译通过,但运行时 x 已销毁,返回的是悬垂指针
}
C 编译器对这段代码不报任何错误——它相信程序员知道自己在做什么。Rust 则选择不相信,并把这种"信任"转化为了一条编译期错误。C 程序员需要几十年经验才能练就的"悬垂指针直觉",在 Rust 中变成了编译器的标准检查项。
不可变与可变引用不可共存:数据竞争的终结
数据竞争的定义在并行编程领域非常明确:多个线程同时访问同一内存位置,且至少有一个访问是写操作,且这些访问之间没有同步机制。 数据竞争的危害在于它是未定义行为(undefined behavior)——程序的行为完全不可预测,可能在某个平台正常运转,在另一个平台崩溃,也可能在相同输入下产生不同结果。
Rust 的解决方案不是检测运行时是否发生了数据竞争,而是在编译期就排除这种可能性。做法正是前两节反复强调的那条规则:
对同一个值,共享引用与可变引用不能同时存在。
这条规则在单线程范围内就已经生效——即使只有一个线程,你在持有 &mut T 的同时尝试创建 &T,编译器也会直接报错。这与许多开发者一开始的直觉相悖:既然只有一个线程,为什么不能既读又写?答案在于,Rust 将这条规则延伸到了多线程场景:如果单线程下的借用规则能保证每一次访问都是安全的,那么当这个程序运行在多个线程上时,线程之间的字节码共享并不会引入新的安全性问题——因为每一个访问发生前,编译器已经确认了该访问在借用规则下是合法的。
用一个类比来理解:想象一间只有一把钥匙的档案室。规则是"任何时刻,要么有多个人同时阅读同一份文件(共享引用),要么有一个人独占房间进行修改(可变引用),两者不可兼得"。在单线程场景下,这把钥匙的规则显得多余——毕竟只有一个人在工作。但正是这套规则,让 Rust 在多线程场景下不需要额外的锁机制就能保证数据安全。
举个例子,下面的代码在编译期就会失败:
fn main() {
let mut data = vec![1, 2, 3];
let shared_ref = &data; // 共享引用,此刻开始 data 不可变
let mutable_ref = &mut data; // 错误:data 已被不可变借用
println!("{}", shared_ref[0]);
}
编译器会指出:shared_ref 后面仍会被使用,因此在它的借用区间内创建冲突的 mutable_ref 不合法。这条规则并非只为多线程数据竞争而设:即便单线程,写入也可能让既有引用或迭代器失效,并破坏编译器基于“共享不可变、可变独占”所做的优化。并发安全是它的重要结果,但别名有效性本身就是核心目标。
这里有一个关键细节需要明确:借用检查器关注的是引用的最后一次使用,而不是引用的声明位置。上面的例子中,shared_ref 在 println! 宏中被使用,因此它的"存活区间"一直延伸到那一行。如果我们将 shared_ref 的使用提前:
fn main() {
let mut data = vec![1, 2, 3];
let shared_ref = &data;
println!("{}", shared_ref[0]); // 最后一次使用共享引用
let mutable_ref = &mut data; // 现在合法:shared_ref 已不再使用
mutable_ref.push(4);
}
这段代码可以正常编译。借用检查器采用了非词法作用域生命周期(Non-Lexical Lifetimes, NLL) 的分析方式——一个引用的生命周期结束于它的最后一次使用,而不是它所在的代码块结束。这比早期 Rust 版本的规则(词法作用域)更为精确,允许了更多合法的代码模式。你不需要手动管理引用的"结束时机",编译器会为你精确计算。
本节的核心收获是:借用规则约束的是有效性与别名权限,而不是建议性的代码风格。下一节先把这些规则放进函数边界,比较 &T、&mut T 与按值参数的 API 契约;引用字段所需的显式生命周期将在文章 16 处理。
引用作为函数参数
前两节讨论的借用规则,都是在单一函数体内的视角:局部变量之间如何共享、如何修改。但真实的程序是由函数组成的——值在函数之间传递时,所有权如何流转?借用规则又如何约束函数签名?这是借用系统从"语法规则"走向"工程实践"的关键一跃。
借用传参:让函数只"看"不"拿"
回顾第 6 篇文章中的结论:将值传递给函数时,默认发生所有权转移(move)。这意味着函数调用结束后,原变量便不再持有该值。
fn describe(s: String) {
println!("长度: {}", s.len());
} // s 在这里被释放
fn main() {
let msg = String::from("hello");
describe(msg);
println!("{}", msg); // 编译错误:msg 的所有权已移入函数
}
对于 String 这类非 Copy 类型,一次读取操作就永久失去所有权,显然不可接受。借用传参正是为此而生:将参数的引用传入函数,而非值本身。
fn describe(s: &String) {
println!("长度: {}", s.len());
} // s 是借用,不拥有 String,函数结束后仅引用失效
fn main() {
let msg = String::from("hello");
describe(&msg); // 传入共享引用,而非移交所有权
println!("{}", msg); // msg 仍然可用
}
函数签名 fn describe(s: &String) 的含义是:该函数接收一个对 String 的共享引用,函数执行期间借用该值,但不拥有它。调用时传入 &msg,借用被创建;函数返回后,借用自动结束,msg 重新恢复完全可用状态。
调用前: msg ──拥有──> 堆上 String
调用时: msg ──拥有──> 堆上 String
&msg ──借用──> 同上(只读)
调用后: msg ──拥有──> 堆上 String(借用结束)
这个模式的价值在于:读取一个值不需要付出所有权转移的代价。任何只需要"查看"数据的函数,都应接收引用而非值本身。
保留所有权:借用不会改变值的归属
借用传参不会把被借用值的所有权交给函数,但调用期间及返回后是否能立即访问原 place,取决于借用是否仍被使用、函数是否返回了与之关联的引用,以及共享/独占权限。可以说所有权责任未转移,不能笼统说“原变量始终可自由使用”。
fn read_len(s: &String) -> usize {
s.len() // 读取字段,允许
}
fn main() {
let msg = String::from("Rust");
let len = read_len(&msg);
// 此处 msg 依然是完全合法的所有者
let len2 = msg.len(); // 没问题,所有权从未离开
println!("{} 和 {}", len, len2);
}
借用不改变所有权这一性质,让代码的推理变得简单:如果一个函数接受引用,就可以确信调用者的变量在调用前后状态一致(除非函数内部做了修改——那是 &mut T 的事)。
这一性质也解释了为什么 &T 是 Copy 类型:引用本身可以被复制,因为复制一个引用不会复制被引用的值,只是多了一个"查看通道"。这在前文讨论 Copy 时已经埋下伏笔——&T 能被反复传递、反复使用,正是因为它不触碰所有权。
返回引用:引用失效的边界
函数不仅可以接受引用,也可以返回引用。但这里有一条重要的限制,也是初学者最容易踩的坑:函数返回的引用必须指向某个活得比函数更长的值。
fn bad() -> &String {
let s = String::from("临时值");
&s // 编译错误:s 在函数返回时被释放,返回的引用成为悬垂引用
}
这个例子违反的正是第 3 节讨论的借用检查规则:引用不能比它指向的值活得更久。s 是函数内的局部变量,函数结束时被释放,返回的 &s 指向一块已释放的内存——借用检查器在编译期拦截了这个错误。
那么,什么样的返回引用是合法的?答案是:返回对某个参数的引用。
fn first_char(s: &String) -> &char {
// 返回对参数 s 所指向的值的引用
&s.chars().next().unwrap()
}
fn main() {
let msg = String::from("hello");
let c = first_char(&msg);
println!("第一个字符: {}", c); // 合法:msg 仍然活着
}
这里的关键在于:first_char 返回的引用指向 s 所引用的值,即调用者传入的那个 String。只要调用者的 msg 在 c 使用期间保持存活,这个引用就是安全的。借用检查器能够识别这种依赖关系:返回引用的生命周期,与参数的引用绑定在一起。
注意:Rust 在底层用**生命周期参数(lifetime parameter)**来精确表达这种绑定关系(写作
fn first_char<'a>(s: &'a String) -> &'a char)。但在这个阶段,只需理解"返回的引用必须指向比函数活得久的值"这一直觉即可。生命周期参数的具体语法将在后续文章展开。
可变引用的返回遵循同样的原则:函数可以返回对参数的可变引用,从而允许调用者在函数返回后继续修改那个值。但如前文所述,可变引用在同一时刻只能存在一个——函数返回 &mut T 时,调用者在函数返回前不能再持有该值的其他引用。
fn append_exclamation(s: &mut String) -> &mut String {
s.push('!');
s // 返回可变引用,调用者可以继续修改
}
fn main() {
let mut msg = String::from("hello");
let result = append_exclamation(&mut msg);
println!("{}", result); // "hello!"
// 此时 msg 的借用已结束,可以直接使用
msg.push_str(" world");
println!("{}", msg); // "hello! world"
}
函数签名 fn append_exclamation(s: &mut String) -> &mut String 的意图是:传入一个可变引用,函数内部进行修改,然后将这个修改的通道交还给调用者。这使得"修改——返回——继续修改"的链式操作成为可能。
本节小结
引用作为函数参数,将借用规则从单一函数内部扩展到了函数边界:传参用 &T 避免所有权转移,函数返回 &T 时必须确保引用不悬垂,函数内部修改用 &mut T 且修改通道可以被传递。这三个模式共同构成了 Rust 中"函数间协作而不移交所有权"的标准写法。
理解到这里,一个新的问题浮出水面:当结构体内部持有引用时,情况会更加微妙——结构体的生命周期如何标注?这正是下一节要讨论的内容:结构体中的引用与生命周期。
解引用操作符初识
共享引用与可变引用提供了访问值的两种路径,但截至目前,我们看到的访问方式都还停留在"把引用交给函数"或"在作用域内借用"的层面。一个更基础的问题尚未回答:拿到一个引用之后,如何真正触达它背后的值? 答案是解引用操作符 *——它是借用系统的"最后一步",也是从"引用"回到"值"的唯一通道。
从引用到值:* 的作用
解引用(dereference)在语法上极为简洁:在引用变量前加上 *,即可访问它指向的值。
fn main() {
let x = 42;
let r = &x; // r: &i32,共享引用
assert_eq!(*r, 42); // *r 取出 x 的值
println!("x = {}", *r);
}
这里的 *r 不再是一个引用,而是 i32 类型的值 42。assert_eq! 可以正常工作,正是因为 *r 已经被"还原"成了普通的值。
这一操作的重要性在于:引用本身只是一个"指向",它不携带值的内容。试图直接使用引用参与运算会产生类型错误:
let x = 10;
let r = &x;
let double = *r * 2; // 正确:先解引用,再做乘法
// let bad = r * 2; // 错误:i32 与 &i32 不能直接相乘
解引用是读取引用背后值的第一步。没有它,引用就只是一个无法被"打开"的地址;有了它,引用才真正成为所有权系统下的安全通道。
通过可变引用修改值
解引用不仅用于读取,也用于修改——前提是引用本身是 &mut T。前文提到可变引用是"握笔改写"的比喻,而 * 正是那只笔实际落纸的动作:
fn main() {
let mut counter = 0;
let r = &mut counter; // r: &mut i32
*r += 1; // 通过解引用修改 counter
*r += 1;
println!("counter = {}", counter); // 输出: counter = 2
}
注意这里的细节:*r += 1 实际上是 *r = *r + 1 的语法糖,它先解引用读取当前值,再通过解引用写回新值。这个"读-改-写"的过程在单一语句中完成,全程都在借用检查器的监视之下——因为 r 是可变引用,这段代码在 r 的作用域内不能同时存在对 counter 的共享引用。
尝试对共享引用解引用并赋值,会触发编译错误:
let x = 5;
let r = &x;
*r = 6; // 错误:cannot assign to `*r`, which is behind a `&` reference
这个错误信息直指问题的本质:解引用操作符的行为,完全由引用的类型决定。&T 只允许读取,&mut T 允许读取与写入。类型系统在编译期就将"可读可写"与"只读"区分开来,不存在运行时才发现的"意外修改"。
方法调用:自动解引用
如果每次访问引用背后的值都必须手动写 *,代码很快会变得冗长。Rust 提供了一项便利:当在引用上调用方法时,编译器会自动插入解引用操作,无需手动书写。
fn main() {
let s = String::from("hello");
let r = &s; // r: &String
// 以下两行完全等价:
let len1 = (*r).len(); // 手动解引用后调用方法
let len2 = r.len(); // 编译器自动解引用
assert_eq!(len1, len2);
println!("长度: {}", len2); // 输出: 长度: 5
}
自动解引用的规则非常简单:当调用 r.method() 时,如果 r 本身没有这个方法,编译器会尝试 (*r).method()。如果还不行,就继续解引用,直到找到匹配的方法或报错。
这一机制同样适用于可变引用:
fn main() {
let mut v = vec![1, 2, 3];
let r = &mut v; // r: &mut Vec<i32>
r.push(4); // 等价于 (*r).push(4)
println!("{:?}", v); // 输出: [1, 2, 3, 4]
}
自动解引用还体现在更复杂的场景中,例如通过共享引用调用方法时,方法内部仍遵循借用规则:
fn main() {
let numbers = vec![10, 20, 30];
let r = &numbers; // 共享引用
let first = r.first(); // 自动解引用,调用 first(&self)
// let _ = r.push(40); // 错误: 共享引用不能调用需要 &mut self 的方法
println!("{:?}", first); // 输出: Some(10)
}
在这里,编译器不仅自动插入了 *,还根据方法的接收者类型(&self 还是 &mut self)来检查引用是否满足要求。push 需要 &mut self,而 r 是共享引用,因此编译失败。自动解引用没有绕过借用规则——它只是省去了手写 * 的冗余动作。
解引用的对称性
将上面三部分放在一起,可以看到一个清晰的对称结构:
| 引用类型 | 解引用读取 | 解引用修改 | 方法调用 |
|---|---|---|---|
&T | ✅ | ❌ 编译错误 | ✅(仅限 &self 方法) |
&mut T | ✅ | ✅ | ✅(&self 与 &mut self 均可) |
这个表也回答了读者心中可能出现的疑问:为什么共享引用不能修改值?为什么方法调用不区分手动与自动?答案是一致的——解引用操作符本身不具备任何"权限",它只是一个通道,权限完全由引用的类型决定。* 既不会加强也不会削弱借用规则,它只是忠实地把引用背后的值暴露出来。
至此,引用系统的核心拼图已经建立:创建引用、借用传参和解引用。显式生命周期标注会在文章 16 系统处理;在此之前,先把引用用于结构体方法、枚举载荷与模式匹配,观察借用如何穿过复合类型。
深水区:重借用、NLL 与内部可变性边界
把 &mut T 传给函数时,编译器通常创建一次较短的重借用,而不是永久移动原可变引用;调用结束且重借用不再使用后,原引用可以继续使用。非词法生命周期(NLL)让借用通常结束于最后一次使用,而不是机械地延续到花括号末尾。方法调用还可能涉及 two-phase borrow,使 v.push(v.len()) 这类先预留可变借用、再计算参数的表达式成立。另一方面,Cell/RefCell 把部分检查移到运行时,Mutex/原子类型用同步维护共享可变性;它们没有推翻借用规则,而是把可变状态封装在 UnsafeCell 支撑的安全抽象中。

1130

被折叠的 条评论
为什么被折叠?



