【Rust语法】引用与借用

请添加图片描述

共享引用 &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 的所有权交出去。一旦函数返回,smain 中就再也无法使用了。这显然不是我们想要的——读取一个值,不应该等于摧毁这个值。解决方案就是 借用(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 的共享引用"。在类型层面,&StringString 是两种完全不同的类型:前者是对后者的引用,后者是值的本体。引用的核心特征是:

  • 不拥有所指向的值,因此不需要在作用域结束时负责清理它
  • 它是一个Copy类型——创建多少个引用都可以,复制引用本身是廉价的
  • 它指向的值位于内存中的某个地址,你可以把它理解为一个"只读窗口"
let x = 42;
let ref1 = &x;   // 第一次借用
let ref2 = &x;   // 第二次借用——完全合法
let ref3 = ref1; // 复制引用本身——合法

这里最重要的一句话是:共享引用是只读的。通过 &T 你能做的只有"看",不能"改"。

只读访问:你能做什么

一个 &T 给了你哪些能力?具体而言:

  1. 读取字段或调用方法:只要该操作只需要读取权
  2. 复制值:如果 T 实现了 Copy trait(如整数、布尔、浮点数)
  3. 创建更多的只读引用:引用可以继续被引用
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 禁止通过这条共享引用路径直接修改值;若类型内部使用 CellRefCell、原子类型或锁,仍可能在共享引用后发生内部可变性。因此规则不是“内存永远不变”,而是“不能通过普通 &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 要如此严苛?回到"思考必须先于行动"的原则——如果允许两个可变引用同时存在,那么 ab 各自都认为自己拥有对数据的完全控制权,它们交替修改同一个值,最终的结果取决于执行顺序,而执行顺序在多线程环境下是不可预测的。这正是数据竞争(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 的借用、移动和使用;“画一张借用图”可以作为直觉,但不是应依赖的正式编译器接口。对于普通引用,最核心的入口规则是:

  1. 共享引用可以同时存在任意多个,但共享引用存在期间,值不能被修改;
  2. 可变引用同一时刻最多存在一个,且可变引用存在期间,值不能被读取。

如果安全代码违反这些规则,编译器会拒绝编译。更准确的保证是: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_refprintln! 宏中被使用,因此它的"存活区间"一直延伸到那一行。如果我们将 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 的事)。

这一性质也解释了为什么 &TCopy 类型:引用本身可以被复制,因为复制一个引用不会复制被引用的值,只是多了一个"查看通道"。这在前文讨论 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。只要调用者的 msgc 使用期间保持存活,这个引用就是安全的。借用检查器能够识别这种依赖关系:返回引用的生命周期,与参数的引用绑定在一起

注意: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 类型的值 42assert_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 支撑的安全抽象中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AI INFRA 阿萨姆

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值