Rust:所有权系统

定义:Ownership system(所有权)

是 Rust 用来管理资源的一套编译期规则: 它让编译器能够在不依赖垃圾回收(GC的前提下,确定资源何时释放,并约束"谁可以访问它、能不能同时访问、能访问多久"。

(资源不只指内存,也包括文件句柄、网络连接、锁等。)


背景:程序为什么必须"管理内存/资源"?

所有程序在运行时都要处理两件事:

  1. 资源什么时候释放(释放太早会崩,太晚会泄漏)
  2. 资源被谁使用、用到什么时候(尤其是"引用/指针/共享"出现时)

在 Rust 之前,主流路径大致有三种:

垃圾回收(GC):Java / Go / JS / C#

  • 优点:程序员通常不用手动决定释放时机;运行时通过可达性分析回收对象。

  • 代价:运行时成本(追踪/扫描/写屏障等)、延迟波动(暂停或抖动,具体取决于 GC 实现),以及更难做"极致可预测"的性能。

手动释放:C(以及部分风格的 C++)

  • 优点:释放时机完全由程序员控制,性能与可控性强。

  • 代价:错误空间巨大且致命:内存泄漏、double free、use-after-free、悬垂指针等。

RAII:C++

RAII(Resource Acquisition Is Initialization,资源获取即初始化)把"释放"绑定到作用域:

对象离开作用域时析构函数自动运行,从而自动释放资源(内存、文件、锁......)。

RAII 很强,它基本解决了"何时释放"的问题;但它并没有从语言层面彻底解决另一半: 当你有指针/引用、共享、并发时,依然可能出现:

  • 对象已析构,但仍然有指针/引用在用(use-after-free)
  • 多处别名同时可写,造成状态不一致
  • 并发下的数据竞争

换句话说:RAII 让"释放动作"自动发生,但"是否还在被使用""是否允许同时使用"在 C++ 里仍主要依赖约定、审查与测试。


Rust 的选择:把"释放时机 + 引用关系"变成可证明的规则

Rust 的路线是:用类型系统 + 编译期检查把很多原本靠约定的事情变成硬规则:

  • 所有权(Ownership):谁拥有值、何时释放、move/copy/drop。
  • 借用(borrow)让"临时访问但不接管所有权"成为受约束的行为
  • 生命周期(lifetime)把"引用能活多久"变成编译器能验证的事实,确保引用永远不会比其指向的数据活得更久

于是 Rust 在不使用 GC 的情况下,尽量把内存安全与线程安全问题前移到编译期:

把一大类"运行时崩溃/未定义行为"改成"编译不过"。


所有权规则:

Rust 的所有权规则,本质是在编译期维持一个不变式: 每一份需要析构(drop)的资源,在任意时刻都必须恰好有一个"析构责任人"(owner)。 这样才能在没有 GC 的前提下,让释放时机确定、释放责任唯一,从根源上避免 double free / use-after-free 这类错误。

TRPL 常见的"三条规则"可以理解为这个不变式在语法层面的教学化表达;它们不是"口号",而是编译器会强制检查、并据此插入/安排 drop 的依据。


规则 1:每个值都有一个所有者(更准确:有一个"拥有位置"负责 drop)

每个值都会被某个"拥有者 onwer"持有,从而编译器知道将来由谁负责调用 drop

rust 复制代码
fn main() {
    let s1 = String::from("hello"); // s1 拥有这段堆内存
}

String::from 创建了一个 String 值,它内部管理一块堆内存(字符串内容)。s1 作为拥有者,承担这份资源的析构责任:

  • 作用域结束时,会自动对 s1 执行 drop(从而释放那块堆内存)
  • 这不是 GC,而是编译器生成的确定性析构

MIR能直观看到 drop(_1):

rust 复制代码
fn main() -> () {
    let mut _0: ();
    let _1: std::string::String;
    scope 1 {
        debug s1 => _1;
    }

    bb0: {
        _1 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
    }

    bb1: {
        drop(_1) -> [return: bb2, unwind continue];
    }

    bb2: {
        return;
    }
}

alloc 里可以看到堆上内容:

rust 复制代码
alloc1 (size: 5, align: 1) {
    68 65 6c 6c 6f                                  │ hello
}

这里提问一个问题, 假设我们把 let 创建出来的 s1 叫做变量,吧在堆上的数据叫做值,那是不是每个值都有一个变量相互绑定呢?

不是的纠正一个容易写错的点: 并不是"Rust 里不存在没有变量的值"。表达式临时值、local 等都可能没有名字。 更准确的说法是:每一份需要析构的资源,都必须能被编译器归属到某个 local ,从而保证 drop 责任明确、路径可追踪。


下面就是没有let 绑定变量的 并且如果创建了值,但是没有绑定变量,则值会在当前语句结束后被 drop

rust 复制代码
fn main() {
    String::from("hello"); //没有绑定变量
    println!("---");
}

观察 MIR

rust 复制代码
fn main() -> () {
    let mut _0: ();
    let _1: std::string::String;
    let _2: ();
    let mut _3: std::fmt::Arguments<'_>;

    bb0: {
        _1 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
    }

    bb1: {
        drop(_1) -> [return: bb2, unwind continue];
    }

    bb2: {
        _3 = Arguments::<'_>::from_str(const "---\n") -> [return: bb3, unwind continue];
    }

    bb3: {
        _2 = std::io::_print(move _3) -> [return: bb4, unwind continue];
    }

    bb4: {
        return;
    }
}

可以观察到 在bb1 执行完后,就立即被 drop(_1) 了

规则 2:同一时刻只能有一个所有者(共享必须显式选择 Rc,Arc)

默认情况下,一个值在同一时刻只能有一个所有者;当发生赋值/传参时,所有权会移动(move)

rust 复制代码
fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // move:所有权从 s1 转移到 s2
    // println!("{s1}"); // 编译错误:s1 不再是所有者,不能再用
    println!("{s2}");
}

可以观察 MIR ,在会重新复制后,发生了 move,原 变量 被标记为不可用

rust 复制代码
fn main() -> () {
    let mut _0: ();
    let _1: std::string::String;
    scope 1 {
        debug s1 => _1;
        let _2: std::string::String;
        scope 2 {
            debug s2 => _2;
        }
    }

    bb0: {
        _1 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
    }

    bb1: {
        _2 = move _1;
        drop(_2) -> [return: bb2, unwind continue];
    }

    bb2: {
        return;
    }
}

alloc1 (size: 5, align: 1) {
    68 65 6c 6c 6f                                  │ hello
}

这里的关键不是"把堆数据搬走了",而是:

move = 把"析构责任 + 使用权"从一个拥有位置转交给另一个 local 。 转交之后,源绑定会被禁止再使用,从而保证"析构责任人仍然唯一"。

用 MIR 也能验证到核心动作是 move _1,并且只对最终 owner 做一次 drop:

rust 复制代码
bb1: {
    _2 = move _1;
    drop(_2) -> [return: bb2, unwind continue];
}

传参数的例子

rust 复制代码
fn main() {
    let s = String::from("hello");
    add_world(s);
    // println!("{}", s);  编译错误:s 不再是所有者,不能再用

}

fn add_world(mut s:String){
    s.push_str("world");
}

在MIR里面虽然看不到 move ,但是他实际上是发生了move的, 因为可以观察到,s 的 drop 的位置,已经不在 main 的作用域,而是在 add_world 的作用域中进行 drop。并且后续再使用 s 会变一报错,从侧面证明 s 确实被 move到了 函数 add_world 中。这里为什么没move 是因为虽然没有显示 表达move语义,但是 drop 责任已经转移,并且源代码层面禁止再使用s,这两者组合起来,语义上就是一次 move。

rust 复制代码
fn main() -> () {
    let mut _0: ();
    let _1: std::string::String;
    let _2: ();
    scope 1 {
        debug s => _1;
    }

    bb0: {
        _1 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
    }

    bb1: {
        _2 = add_world(copy _1) -> [return: bb2, unwind continue];
    }

    bb2: {
        return;
    }
}

alloc1 (size: 5, align: 1) {
    68 65 6c 6c 6f                                  │ hello
}

fn add_world(_1: String) -> () {
    debug s => _1;
    let mut _0: ();
    let _2: ();
    let mut _3: &mut std::string::String;
    let mut _4: &str;

    bb0: {
        _3 = &mut _1;
        _4 = const "world";
        _2 = String::push_str(move _3, move _4) -> [return: bb1, unwind: bb3];
    }

    bb1: {
        drop(_1) -> [return: bb2, unwind continue];
    }

    bb2: {
        return;
    }

    bb3 (cleanup): {
        drop(_1) -> [return: bb4, unwind terminate(cleanup)];
    }

    bb4 (cleanup): {
        resume;
    }
}

为什么 i32 这种例子又能"复制"而不是 move?

rust 复制代码
fn main() {
    let x = 5;
    let y = x;        // Copy:复制一份值
    println!("{x}");  // OK
    println!("{y}");  // OK
}

这里需要引入 copy trait 的概念,因为这种不需要drop不需要析构函数的数据类型为了用起来更自然,类似一种优化。

目地是为了让像 i32 这种"复制一份没有任何管理问题"的类型用起来更自然(更接近数学值/标量的直觉)。

并且复制不会改变资源所有权/唯一性,不影响之前提到的规则,因为他们不需要 drop ,自然不需要关心是否move,是否会忘记释放堆内存资源。

  • 非 Copy 类型 :let b = a; 默认是 move 语义是"把值交给 b,a 失效",以保证最后只有一个地方负责 drop。
  • Copy 类型 :let b = a; 是 copy 语义是"复制出一份给 b,a 仍有效"。

这里没有 move、也没有 drop 需要协调(i32 没有析构负担)。

这一条规则带来的直接收益: 防止 double free(不会出现两个 owner 各 drop 一次同一资源) 并进一步让"谁负责释放"可被编译器静态追踪。

总结下就是,对直接赋值的情况,rust 对实现了copy trait的类型会直接按位复制,没有实现 copy trait 类型的就会直接 move 。

Rust已经对一系列基础类型实现了Copy,比如所有纯数值标量类型i32等数值、浮点型、布尔、字符,以及裸指针等等。

如果我就是想"堆上完整复制一份数据"(深拷贝)怎么办?

这时你要显式选择 Clone(付出复制成本):

rust 复制代码
fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // 深拷贝
    println!("{s1}");
    println!("{s2}");
}

String::clone 的语义就是复制底层 buffer:

rust 复制代码
impl Clone for String {
    fn clone(&self) -> Self {
        String { vec: self.vec.clone() }
    }
}

"设计哲学总结": Rust 默认 move,避免在无意中制造昂贵拷贝和所有权混乱;

需要复制时用 Clone 显式表达"我愿意付出成本"。

补充:这条规则"不绝对"吗?也就是有没有一个值有多个所有者的情况?

有的,Rc/Arc 允许共享所有权,但它不是"绕过规则",而是显式选择另一套所有权协议(引用计数):

  • 每次 clone 增加计数
  • 最后一个 owner 被 drop 时才释放资源 这仍然符合"析构责任可追踪",只是责任从"唯一 owner"变成了"计数协议的最后一个"。

例如需求是:

  • "A 要长期持有它;同时 B 也要长期持有它;A/B 谁先结束都不确定;只要还有任何一方活着就不能 drop"

这种情况下,只有 move 还有 copy 就没办法实现了,但是这里最好放在 借用,引用之后再讲,属于打补丁。


规则 3:所有者离开作用域(或被覆盖写入),值会被立即 drop

当 owner 的生存范围结束时,值会被丢弃(调用 drop)。 另外,当同一块"lcoal"被重新赋值时,旧值会在写入前先被 drop,避免泄漏。

作用域结束 drop(确定性析构)

rust 复制代码
fn main() {
   {
        let x = String::new();
   } // 这里 drop(x)
    let y = String::new();
}

MIR 会在作用域边界插入 drop(_1) / drop(_2),下面额 MIR 已经展示得很清楚了。

rust 复制代码
fn main() -> () {
    let mut _0: ();
    let _1: std::string::String;
    let _2: std::string::String;
    scope 1 {
        debug x => _1;
    }
    scope 2 {
        debug y => _2;
    }

    bb0: {
        _1 = String::new() -> [return: bb1, unwind continue];
    }

    bb1: {
        drop(_1) -> [return: bb2, unwind continue];
    }

    bb2: {
        _2 = String::new() -> [return: bb3, unwind continue];
    }

    bb3: {
        drop(_2) -> [return: bb4, unwind continue];
    }

    bb4: {
        return;
    }
}

这一条把 C++ RAII 从"建议/习惯"变成了编译器保证的默认语义: 正常返回、提前返回、(unwind 的)异常路径上都能自动清理资源。

赋值覆盖:写入同一 place 前会先 drop 旧值(避免泄漏)

rust 复制代码
fn main() {
    let mut s1 = String::from("hello");
    s1 = String::from("world");
}

给的 MIR 里能看到典型模式:

rust 复制代码
fn main() -> () {
    let mut _0: ();
    let mut _1: std::string::String;
    let mut _2: std::string::String;
    scope 1 {
        debug s1 => _1;
    }

    bb0: {
        _1 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
    }

    bb1: {
        _2 = <String as From<&str>>::from(const "world") -> [return: bb2, unwind: bb6];
    }

    bb2: {
        drop(_1) -> [return: bb3, unwind: bb4];
    }

    bb3: {
        _1 = move _2;
        drop(_1) -> [return: bb5, unwind continue];
    }

    bb4 (cleanup): {
        _1 = move _2;
        goto -> bb6;
    }

    bb5: {
        return;
    }

    bb6 (cleanup): {
        drop(_1) -> [return: bb7, unwind terminate(cleanup)];
    }

    bb7 (cleanup): {
        resume;
    }
}
  1. 构造新值到临时 _2
  2. drop(_1) 先清理旧值(hello)
  3. _1 = move _2 再把新值写入同一 place

这就是"覆盖赋值不泄漏"的编译期保障。

那 let s1 = ...; let s1 = ...;(shadowing)又是什么情况?

rust 复制代码
fn main() {
    let s1 = String::from("hello");
    let s1 = String::from("world");
}

这里很多人直觉以为"第二行把第一行覆盖了,所以 hello 会立刻 drop",但 shadowing 的本质是:

shadowing 改的是"名字绑定"(新建一个绑定),不是"同一内存位置的写入"。

rust 复制代码
fn main() -> () {
    let mut _0: ();
    let _1: std::string::String;
    scope 1 {
        debug s1 => _1;
        let _2: std::string::String;
        scope 2 {
            debug s1 => _2;
        }
    }

    bb0: {
        _1 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
    }

    bb1: {
        _2 = <String as From<&str>>::from(const "world") -> [return: bb2, unwind: bb5];
    }

    bb2: {
        drop(_2) -> [return: bb3, unwind: bb5];
    }

    bb3: {
        drop(_1) -> [return: bb4, unwind continue];
    }

    bb4: {
        return;
    }

    bb5 (cleanup): {
        drop(_1) -> [return: bb6, unwind terminate(cleanup)];
    }

    bb6 (cleanup): {
        resume;
    }
}

所以旧的那个 s1(第一份 String)仍然会按作用域的析构顺序在后面被 drop(你 MIR 里体现为两个局部 _1、_2 最终都要 drop,只是 drop 的先后顺序不同)。

你可以把这段总结成一句读者很容易记住的话:

  • 赋值:改"lcoal",写入前必须 drop 旧值
  • shadowing:改"name",旧绑定不必立刻 drop,但析构顺序仍然确定

三条规则合起来,到底解决了什么?

把它们串成一条因果链,读者就不会觉得你在"照本宣科":

  • 规则 2(唯一 owner / 默认 move)确保不会出现多个析构责任人 → 消灭 double free
  • 规则 3(作用域/覆盖点 drop)确保析构时机确定、路径可推理 → 避免资源泄漏/资源悬空
  • 规则 1(责任可归属到某个 owning place)让编译器知道该在哪里插入 drop → 释放责任明确

更精炼的一句话: Ownership + Drop = 资源生命周期的确定性管理。


只有所有权还不够------我们还需要"借用"

仅靠所有权规则,当然能写程序,但会很别扭:很多"只是临时用一下,改一下"的场景会被迫走 move + 返回值这一套。

例如这个例子(非常典型):

rust 复制代码
fn main() {
    let s1 = String::new();
    let s2 = push_hello(s1);
    dbg!(s2);
}

fn push_hello(mut s: String) -> String {
    s.push_str("hello");
    s
}

这能工作,但直觉上我们更希望:

  • 不接管 s1 的析构责任(owner 仍然是调用方)
  • 只临时拿到"使用权"(读/写权限)做操作

于是就引出下一节:借用与引用,把"使用权"从"所有权/析构责任"里拆出来:

rust 复制代码
fn main() {
    let mut s1 = String::new();
    push_hello(&mut s1);
    dbg!(s1);
}

fn push_hello(s: &mut String) {
    s.push_str("hello");
}

引用和借用:

上一节我们把所有权总结成一句话:

每份需要析构的资源,任意时刻都必须恰好有一个"析构责任人"(owner)。

但如果只有所有权(默认 move 的场景下),很多 只是临时看一眼改一下 的场景都会变得别扭:你不得不把值 move 进函数,再 move 出来:

rust 复制代码
fn push_hello(mut s: String) -> String {
    s.push_str("hello");
    s
}

能用,但不够自然。我们真正想表达的是:

  • 调用方仍然是 owner(仍然负责 drop)
  • 函数只是暂时获得访问权限(读/写),并且访问结束就归还

这就引出 Rust 的第二根支柱:借用(borrow) ,用 引用(reference)来承载 暂时的使用权。


引用是什么:不拥有资源,只携带访问权限

引用分两种:

  • &T:不可变引用(共享借用,shared borrow)------只读
  • &mut T:可变引用(可变借用,mutable borrow)------可读写

它们都不拥有被引用的值,因此:

  • 引用离开作用域时 不会 drop 被引用的资源
  • 被引用的资源仍由原 owner 在其作用域结束时 drop
rust 复制代码
fn main() {
    let s = String::from("hello");     // owner:s
    let r = &s;                        // r 借用 s(只读)
    println!("{r}");
} // 这里只 drop(s),不会 drop(r) 指向的"hello"两次
rust 复制代码
fn main() -> () {
    let mut _0: ();
    let _1: std::string::String;
    let _3: ();
    let mut _4: std::fmt::Arguments<'_>;
    let mut _6: &&std::string::String;
    let mut _8: core::fmt::rt::Argument<'_>;
    let mut _9: &[u8; 4];
    let _10: &[core::fmt::rt::Argument<'_>; 1];
    let mut _11: &&std::string::String;
    scope 1 {
        debug s => _1;
        let _2: &std::string::String;
        scope 2 {
            debug r => _2;
            let _5: (&&std::string::String,);
            scope 3 {
                debug args => _5;
                let _7: [core::fmt::rt::Argument<'_>; 1];
                scope 4 {
                    debug args => _7;
                }
            }
        }
    }

    bb0: {
        _1 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
    }

    bb1: {
        _2 = &_1;
        _6 = &_2;
        _5 = (move _6,);
        _11 = copy (_5.0: &&std::string::String);
        _8 = core::fmt::rt::Argument::<'_>::new_display::<&String>(copy _11) -> [return: bb2, unwind: bb6];
    }

    bb2: {
        _7 = [move _8];
        _9 = const b"\xc0\x01\n\x00";
        _10 = &_7;
        _4 = Arguments::<'_>::new::<4, 1>(move _9, copy _10) -> [return: bb3, unwind: bb6];
    }

    bb3: {
        _3 = std::io::_print(move _4) -> [return: bb4, unwind: bb6];
    }

    bb4: {
        drop(_1) -> [return: bb5, unwind continue];
    }

    bb5: {
        return;
    }

    bb6 (cleanup): {
        drop(_1) -> [return: bb7, unwind terminate(cleanup)];
    }

    bb7 (cleanup): {
        resume;
    }
}

其他语言的引用,不会检查在使用的时候,值是否还存在,所以经常会出现想用一个值的时候发现他的值已经被释放了的经典 bug ,但是 rust 编译器专门加了一套规则检查,用来保证不会出现类似的问题,所有的引用都是安全的,在同一时间,只会存在多个共享读引用,或者只有一个写引用。同时原来的 owner(那个绑定变量)仍然拥有所有权 ,但在 借用活着的期间,它的"直接访问权"(读/写)会被暂停,所以不会出现"两个地方都能写"。

这套规则下的引用,rust 叫做借用

借用检查的核心规则:

  1. 一个变量允许存在多个不可变借用,或一个可变借用

  2. 如果存在不可变借用,所有者暂时失去写权限,只能读取

  3. 如果存在可变借用,所有者暂时失去读写权限

  4. 只要存在任意借用,所有者暂时失去释放与移动权限

  • 一个变量允许存在多个不可变借用,或一个可变借用

以下两段代码都是合法的:

rust 复制代码
let s = String::from("hello");
let b1 = &s;
let b2 = &s;
let b3 = &s;

println!("print by borrow: {}", b1);
println!("print by borrow: {}", b2);
println!("print by borrow: {}", b3);
rust 复制代码
let mut s = String::from("hello");
let b1 = &mut s;
b1.push_str(", world");

第一段代码是一个变量被多次不可变借用,此时多个不可变借用可以同时使用,依次进行println!操作。

第二段代码是一个变量进行了一次可变借用,并通过可变借用修改数据。

除了以上两种情况,其它借用场景都不合法。例如同时存在可变借用和不可变借用,或者存在多个可变借用。

  • 如果存在不可变借用,所有者暂时失去写权限,只能读取
rust 复制代码
let s = String::from("hello");
let b1 = &s;
let b2 = &s;

// s.push_str(", world"); errorprintln!("print by borrow: {}", s);
println!("print by borrow: {}", b1);
println!("print by borrow: {}", b2);

以上代码中,s是字符串的所有者,当它进行两次不可变借用后,依然可以通过s读取字符串的内容,但是不允许进行s.push_str。也就是存在不可变借用会失去写权限,保留读权限。

  • 如果存在可变借用,所有者暂时失去读写权限
rust 复制代码
let mut s = String::from("hello");
let b1 = &mut s;

// println!("print by s: {}", s); error// s.push_str(", world");         errorprintln!("print by borrow: {}", b1);

以上代码,s进行了一次可变借用,期间s尝试读取和修改字符串都失败了。因为存在可变借用期间,所有者会失去读写权限。

  • 只要存在任意借用,所有者暂时失去释放与移动权限
rust 复制代码
let s = String::from("hello");
let r = &s;

// let s2 = s; error// drop(s); errorprintln!("{}", r);

所以什么是借用?总结下就是加了 只能独占写,或者多个共享读的限制的 引用,从而避免产生数据竞争,引用失效等问题。

NLL (这里可以生命周期看完在回来学)

Rust 2018引入了智能的借用检查Non-Lexical Lifetimes (NLL) 优化,能够分析借用的实际使用范围:

rust 复制代码
let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // r1 和 r2 在这里不再被使用,生命周期结束
let r3 = &mut s; // 现在可以创建可变引用
println!("{}", r3); // r3 生命周期结束

编译器能够分析出r1和r2在println!之后不再使用,所以允许后续创建可变引用。这比简单的词法作用域更智能,提高了代码的灵活性。


重新借用 reborrow

基于NLL,Rust可以更灵活地判断借用的生命周期,于是衍生出了重新借用的语法。

对一个已经存在的借用,可以再次进行借用

  • 原借用为可变借用,此时原借用暂时失效,直到新借用生命周期结束
  • 原借用为不可变借用,原借用仍然生效

例如:

rust 复制代码
fn main() {
        let mut s = String::from("hello");
        let b1 = &mut s;         // b1 start
        let b2 = &*b1;    // b2 start
        print!("{}", b2); // b2 end
        
        b1.push_str(", world!"); // b1 end
}

首先b1对s进行了一次可变借用,随后let b2 = &*b1;就是重新借用,b2基于b1进行重新借用,在b2生命周期期间,b1无法使用,当b2结束才把所有权归还b1。

分析一下这个语法&*b1,b1本身是一个借用,*b1拿到的是b1指向的值,这是一个位置表达式。随后对其进行借用&*b1,相当于对b1指向的值进行借用。

有人问,这个重新借用难道不是破坏了之前的借用规则吗?这里同时存在了可变借用和不可变借用,实际上没有。

因为NLL的存在,Rust可以非常智能地分析借用的生命周期,在任意时刻,都是遵循之前的借用规则的。

当一个可变借用被重新借用后,在重新借用存在期间,原借用不能使用,分析借用规则时暂时当做原借用不存在。

一起分析以下代码:

rust 复制代码
fn main() {
        let mut s = String::from("hello");
        let b1 = &mut s;         // 可变借用: b1 
        let b2 = &*b1;           // 不可变借用: b2 
        let b3 = &*b1;           // 不可变借用: b2 b3
        print!("{}", b3);        // 不可变借用: b2 b3
        print!("{}", b2);        // 不可变借用: b2
        
        b1.push_str(", world!"); // 可变借用: b1 
}

一开始,只有b1这个可变借用,并且没有其他借用,合法。

创建b2后,此时b1暂时不能使用了,当做b1不存在,只有b2不可变借用独占,合法。

创建b3后,此时相当于有两个不可变借用b2和b3,允许存在多个不可变借用,合法。

当b2和b3依次离开生命周期,此时b1所有权恢复,只有b1可变借用独占,合法。

可以看出来,将代码拆成一行一行看,把已经被重新借用的原借用暂时忽略,任何时刻都是符合借用规则的。

再看一个重新借用的例子:

rust 复制代码
fn main() {
        let mut s = String::from("hello");
        let b1 = &mut s;         // 可变借用: b1 
        let b2 = &mut *b1;       // 可变借用: b2 
        print!("{}", b2);        // 可变借用: b2
        
        b1.push_str(", world!"); // 可变借用: b1 
}

和刚才一样分析:

一开始,只有b1这个可变借用,并且没有其他借用,合法。

创建b2后,此时b1暂时不能使用了,当做b1不存在,只有b2可变借用独占,合法。

当b2离开生命周期,此时b1所有权恢复,只有b1可变借用独占,合法。

可以看出,重新借用是对借用规则的锦上添花,而不是对借用规则的破坏性改写。

基于重新借用,你可以将一个可变借用的权限临时缩小为不可变借用,

在传参时也可以创建一个生命周期更短的临时引用。

借用解决了什么问题

  • 所有权(ownership)回答:谁负责 drop

  • 借用(borrowing)回答:谁在某段时间内可以读/写

  • 借用检查的目标是让"访问权限"也满足可证明的规范,从而让编译器将相关问题检查出来: 要么多读共享,要么独占可写,并且不产生悬垂引用

那既然借用不拥有数据的所有权,那这个时候如果数据的 onwer 被drop了,借用还能用吗?那显然不可以, 其他语言有空指针表示 该指针没有指向任何位置,rust 里面可没有空借用,但是这种情况理论上是有可能出现的,例如一个借用在使用期间,他的onwer 就被drop了。 那编译器的借用检查能加检查出来吗?当然可以,这就是不产生悬垂引用的保证,那怎么检查出来的呢?通过什么方式或者什么规则?

这就是下一节

生命周期

生命周期存在的目的:

上一节我们把借用讲成"临时的使用权"。但引用有一个先天限制:

借用不拥有数据,因此编译器必须证明: 借用的有效期一定不超过被引用数据的存活期。 否则就会出现悬垂引用(dangling reference)------指向已被释放/已离开作用域的数据。

这就是生命周期系统要做的事情。判断一个借用的生命周期是否正确。借用的有效期是否真正有效。目的是防止借用期间,数据被drop,导致的悬垂引用。

  • 所有权:谁负责 drop
  • 借用:谁能读/写
  • 生命周期:读/写的这段时间是否安全(引用是否可能悬垂)

生命周期要禁止的最典型错误:悬垂引用

下面代码在 Rust 里会被拒绝:

rust 复制代码
fn main() {
    let r: &String;

    {
        let s = String::from("hello");
        r = &s; // r 借用 s
    } // s 在这里 drop
    println!("{r}"); // r 指向的 s 已经不存在
}

直觉解释:s 的资源在内层作用域结束时就被释放了,而 r 想在外层继续用它。

rust 复制代码
   Compiling playground v0.0.1 (/playground)
error[E0597]: `s` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let s = String::from("hello");
  |             - binding `s` declared here
6 |         r = &s; // r 借用 s
  |             ^^ borrowed value does not live long enough
7 |     } // s 在这里 drop
  |     - `s` dropped here while still borrowed
8 |     println!("{r}"); // r 指向的 s 已经不存在
  |                - borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `playground` (bin "playground") due to 1 previous error

Rust 的做法不是"运行时检查",而是在编译期拒绝这种代码

对于简单的生命周期关系,编译器大多数时候可以自己识别,但是有时候会有一些复杂的情况:

rust 复制代码
fn longer_str(s1: &str, s2: &str) -> &str {
    if s1.len() > s2.len(){
        s1
    }else{
        s2
    }
}

fn main() {
    let ret : &str;
    let s1: &str = "hello";
    {
        let s2: &str = "world";
        ret = longer_str(s1, s2);

        println!("{ret}");
    }
      println!("{ret}");
}

编译器报错:

rust 复制代码
1 | fn longer_str( x: &str, y: &str) -> &str {
  |                   ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
1 | fn longer_str<'a>( x: &'a str, y: &'a str) -> &'a str {
  |              ++++      ++          ++          ++

这种情况下就需要显示标注生命周期,告诉编译器,这里的到底该怎么处理,

rust 复制代码
fn longer_str<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len(){
        s1
    }else{
        s2
    }
}

fn main() {
    let ret : &str;
    let s1: &str = "hello";
    {
        let s2: &str = "world";
        ret = longer_str(s1, s2);

        println!("{ret}");
    }
      println!("{ret}");
}

关键理解:是借用在藐视生命周期,而非生命周期约束借用

longer_str<'a>, 声明生命周期 'a 此时的生命周期没有任何含义

s1: &'a str, s2: &'a str ,'a 的含义就变成了 min(s1的生命周期,s2的生命周期)

返回值 &'a str ,返回值的生命周期至少是 x和y的最小值。描述返回值的生命周期,从而让rust 编译器能够对 返回值的借用进行检查。避免数据竞争以及悬垂引用。

那s1,s2的生命周期是什么呢?就是从s1/s2生命之后,到作用域结束。

生命周期标注不是"延长寿命",而是"描述关系"

很多初学者看到 <'a> 会误解成"给引用续命"。

生命周期参数只是在类型层面描述引用之间、以及引用与输入数据之间的约束关系; 它不会让任何东西多活一秒。

还是上面的例子,其实就是当编译器无法推断返回值的生命周期,从而无法判断这个借用是否合法的时候,通过生命周期关系描述,告诉编译器,返回值的生命周期,也就是输出生命周期,等于 相关的输入生命周期的最小值


函数中生命周期省略规则

我们可能发现一个问题,并不是所有的rust函数都有奇怪的 <'a> 生命周期标注,就像上面的例子中的一样,只有在 Rust 编译器识别不出来的时候,才需要我们程序员去进行标注,声明,其余情况是不需要标注的

  1. 对没有生命周期标注的函数参数,自动给不同参数分配不同的生命周期

  2. 如果只有一个输入生命周期,该生命周期自动分配给输出生命周期

  3. 如果存在多个生命周期,切第一个参数为 &self 或者 &mut self,则将 self 的生命周期分配给输出生命周期

  4. 对没有生命周期标注的函数借用参数,自动给不同参数分配不同的生命周期,例如:

rust 复制代码
fn longer_str(s1:  &str, s2: &str) {
   ....
}
//给每个参数自动分配一个生命周期

fn longer_str<'a,'b>(s1: &'a str, s2: &'b str) {
   ....
}

并且,也适用于已声明部分生命周期参数的情况:

rust 复制代码
// 源代码:
fn multiple_borrow<'a>(x: &'a str, y: &str, m: &str, n: &str)

// 补全后:
fn multiple_borrow<'a, 'b, 'c, 'd>(x: &'a str, y: &'b str, m: &'c str, n: &'d str)

这份东西的意义就是,我们只需要标注与输出生命周期相关的 输入生命周期即可,其他的可以让那自动补全。

  1. 如果只有一个输入生命周期,该生命周期自动分配给输出生命周期
rust 复制代码
fn longer_str(s1:  &str) -> &str {
   s1
}

//自动补全后
//第一步
fn longer_str<'a>(s1:  &'a str) -> &str {
   s1
}
//第二步
fn longer_str<'a>(s1:  &'a str) -> &'a str {
   s1
}
  1. 如果存在多个生命周期,切第一个参数为 &self 或者 &mut self,则将 self 的生命周期分配给输出生命周期
rust 复制代码
struct Example<'e> {
    data: &'e str,
}

impl<'e> Example<'e> {
    fn get_data(&self, _other: &str) -> &str {
        self.data
    }
}

此处的 get_data 没有注明生命周期,经过编译器补全,函数签名如下:

rust 复制代码
// 源代码
fn get_data(&self, _other: &str) -> &str
// 规则一:
fn get_data<'a, 'b>(&'a self, _other: &'b str) -> &str
// 规则三:
fn get_data<'a, 'b>(&'a self, _other: &'b str) -> &'a str

所以经过以上规则我们知道,通常有多个借用参数,并且返回值也是借用的时候,才需要我们自己去手动标注生命周期,例如:

rust 复制代码
fn longer_str(s1:  &str, s2: &str) -> &str{
   ....
}

在结构体里的借用:必须把借用的生命周期写进类型

上面我们看了方法里的生命周期,接下来我们看下生命周期也非常常见的位置,结构体中的生命周期。

只要一个类型里存了借用,它就必须在类型层面携带生命周期参数,表达"这个结构体实例不能活得比它借用的数据更久"。

rust 复制代码
struct Borrow<'a> {
    s: &'a str,
}

方法里的里的生命周期:

同时如果对该结构体实现方法,需要在 tmpl后面声明生命周期参数 .因为Borrow 自带一个 生命周期,在实现任何方法的时候都要带上。

rust 复制代码
struct Borrow<'a> {
    s: &'a str,
}

impl<'a> Borrow<'a> {
    fn new(s: &'a str) -> Borrow<'a> {
        Borrow { s }
    }
}

Trait 里的生命周期:

rust 复制代码
trait Longer<'l> {
    fn longer_str(&self) -> &'l str;
}

struct Borrow<'b> {
    x: &'b str,
    y: &'b str,
}

impl<'a> Longer<'a> for Borrow<'a> {
    fn longer_str(&self) -> &'a str {
        if self.x.len() > self.y.len() {
            self.x
        } else { 
            self.y
        }
    }
}

匿名生命周期

因为有时候生命周期不能省略,但是有时候我们会写出的代码会非常啰嗦,可以用匿名生命周期去做推断。

rust 复制代码
struct Foo<'a>(&'a str);

impl<'a> Foo<'a> {
    fn get(&self) -> &'a str {
        self.0
    }
}

//也可以这样实现:让编译器自动推断

impl  Foo<'_> {
    fn get(&self) -> &str {
        self.0
    }
}

静态生命周期'static:能活到程序结束的引用(以及常见误解)

'static 表示"在整个程序运行期间都有效"的引用。最常见的是字符串字面量:

ini 复制代码
let s: &'static str = "hello";

因为字面量通常放在只读数据段,天然是 'static。

但要提醒读者一个关键点:

'static 不是"我想让它活久一点就能标成 static"。 想得到 'static 引用,你必须真的引用到 'static 数据(例如字面量、全局常量、或泄漏出来的内存等)。

在 API 设计上,除非你确实需要"永远有效",否则不要轻易把参数写成 &'static T,那会把可用输入限制得非常死(比如无法传入来自局部变量的借用)。


相关推荐
Ranger09296 小时前
鸿蒙开发新范式:Gpui
rust·harmonyos
DongLi013 天前
rustlings 学习笔记 -- exercises/05_vecs
rust
番茄灭世神4 天前
Rust学习笔记第2篇
rust·编程语言
shimly1234564 天前
(done) 速通 rustlings(20) 错误处理1 --- 不涉及Traits
rust
shimly1234564 天前
(done) 速通 rustlings(19) Option
rust
@atweiwei4 天前
rust所有权机制详解
开发语言·数据结构·后端·rust·内存·所有权
shimly1234564 天前
(done) 速通 rustlings(24) 错误处理2 --- 涉及Traits
rust
shimly1234564 天前
(done) 速通 rustlings(23) 特性 Traits
rust
shimly1234564 天前
(done) 速通 rustlings(17) 哈希表
rust