XiaoboTalk

所有权与引用及生命周期

1、所有的变量绑定,都有作用域管着,超出作用域就无法使用

struct Point(i32, i32); fn main() { { let p = Point(3, 4); println!("x: {}", p.0); } println!("y: {}", p.1); }
  • 超出作用域后,所有的变量都被释放
  • 通常说变量拥有一个值

2、通过赋值(=)操作,可以在变量之间转移所有权

对于普通的标量类型,赋值(=) 执行的是栈拷贝。但是对于对象类型(String),默认情况,赋值会变为转移所有权。
fn main() { let s1: String = String::from("Hello!"); let s2: String = s1; println!("s2: {s2}"); // println!("s1: {s1}"); }
  • s1 拥有的 Hello 字符串,通过赋值,转移给了 s2。
  • s1 出了作用域,对它来说,相当于什么都没发生,它也不拥有任何值。
  • s2 出了作用域,它所持有的值会被释放。
  • 所有权转移后,s1 不可访问。
 
Stack Heap .- - - - - - - - - - - - - -. .- - - - - - - - - - - - - -. : : : : : s1 : : : : +-----------+-------+ : : +----+----+----+----+ : : | ptr | o---+---+-----+-->| R | u | s | t | : : | len | 4 | : : +----+----+----+----+ : : | capacity | 4 | : : : : +-----------+-------+ : : : : : `- - - - - - - - - - - - - -' : : `- - - - - - - - - - - - - -'
After move to s2:
Stack Heap .- - - - - - - - - - - - - -. .- - - - - - - - - - - - - -. : : : : : s1 "(inaccessible)" : : : : +-----------+-------+ : : +----+----+----+----+ : : | ptr | o---+---+--+--+-->| R | u | s | t | : : | len | 4 | : | : +----+----+----+----+ : : | capacity | 4 | : | : : : +-----------+-------+ : | : : : : | `- - - - - - - - - - - - - -' : s2 : | : +-----------+-------+ : | : | ptr | o---+---+--' : | len | 4 | : : | capacity | 4 | : : +-----------+-------+ : : : `- - - - - - - - - - - - - -'
 

同样的事,C++ 会进行一次值拷贝

std::string s1 = "Cpp"; std::string s2 = s1; // 把 s1 的堆内存的值拷贝一份新的,s2 指向新的堆内存,也就是各自独有一份
Stack Heap .- - - - - - - - - - - - - -. .- - - - - - - - - - - -. : : : : : s1 : : : : +-----------+-------+ : : +----+----+----+ : : | ptr | o---+---+--+--+-->| C | p | p | : : | len | 3 | : : +----+----+----+ : : | capacity | 3 | : : : : +-----------+-------+ : : : : : `- - - - - - - - - - - -' `- - - - - - - - - - - - - -'
拷贝赋值之后:
Stack Heap .- - - - - - - - - - - - - -. .- - - - - - - - - - - -. : : : : : s1 : : : : +-----------+-------+ : : +----+----+----+ : : | ptr | o---+---+--+--+-->| C | p | p | : : | len | 3 | : : +----+----+----+ : : | capacity | 3 | : : : : +-----------+-------+ : : : : : : : : s2 : : : : +-----------+-------+ : : +----+----+----+ : : | ptr | o---+---+-----+-->| C | p | p | : : | len | 3 | : : +----+----+----+ : : | capacity | 3 | : : : : +-----------+-------+ : : : : : `- - - - - - - - - - - -' `- - - - - - - - - - - - - -'
上边的 C++ 代码,表面上是赋值,实际上是拷贝,这在 C++ 很不确定,根据具体的类型而定。
C++ 也有专门的 move 函数: std:move
std::string s1 = "Cpp"; s2 = std::move(s1) // 注意 s1 依然有效,但会变为未指定初始值的状态

函数传参-默认会转移所有权

fn say_hello(name: String) { println!("Hello {name}") } fn main() { let name = String::from("Alice"); say_hello(name); // say_hello(name); // 会报编译错误,所有权已经被转移,不能再次访问 name }

Copying & Cloning

赋值操作,默认会转移所有权,但对于一些特定类型,赋值操作默认行为会变为 copy
fn main() { let x = 42; let y = x; println!("x: {x}"); println!("y: {y}"); }
因为 integer 实现了 Copy trait ,所以赋值实际行为是 Copy 值。
 
你也可以在自定义的类型中,使用 Copy 语义
#[derive(Copy, Clone, Debug)] // copy 宏 struct Point(i32, i32); fn main() { let p1 = Point(3, 4); let p2 = p1; println!("p1: {p1:?}"); println!("p2: {p2:?}"); }
  • 赋值后,p1p2 都有各自的数据
  • 也可以通过 p1.clone() 显式的 copy 数据
 

Copying 和 Cloning 的异同

  • Copying 指发生在内存区域的按位复制 (内存布局完全一致),不是任意的对象都可以 Copy
  • Copying 不能自定义实现
  • Cloning 克隆是一种更长久的操作,它允许自定义克隆的行为,只需自己实现 Clone trait 即可。
  • Copying 对于那些实现了 Drop trait 的数据类型不能使用

Borrowing

#[derive(Debug)] struct Point(i32, i32); fn add(p1: &Point, p2: &Point) -> Point { let p = Point(p1.0 + p2.0, p1.1 + p2.1); println!("&p.0: {:p}", &p.0); p } pub fn main() { let p1 = Point(3, 4); let p2 = Point(10, 20); let p3 = add(&p1, &p2); println!("&p3.0: {:p}", &p3.0); println!("{p1:?} + {p2:?} = {p3:?}"); }
  • add 函数借用两个指针,返回了一个新指针
  • 调用函数者拥有函数返回值的所有权 (p3)
  • add 函数打印的 p 地址值和 p3 的在 Debug 环境会不同,因为 Debug 环境做了 Copy;但是在 Release 环境,编译器会把 Copy 擦除,不进行拷贝。效率更高。这被称为 RVO ( Return Value Optimization)
RVO 是一种拷贝优化,它允许函数的返回值直接构造在调用者提供的内存空间中,而不需要额外的拷贝操作。
对象拷贝的开销比较大,在 C++ 中也有拷贝优化,但是由于可能有副作用 (修改对象的状态等),所以在 C++ 中拷贝需要在语言规范中明确定义。

可变拷贝和不可变拷贝

fn main() { let mut a: i32 = 10; let b: &i32 = &a; { let c: &mut i32 = &mut a; *c = 20; } println!("a: {a}"); println!("b: {b}"); }
上边的代码会编译报错:
error[E0502]: cannot borrow `a` as mutable because it is also borrowed as immutable --> main.rs:6:27 | 3 | let b: &i32 = &a; | -- immutable borrow occurs here ... 6 | let c: &mut i32 = &mut a; | ^^^^^^ mutable borrow occurs here ... 11 | println!("b: {b}"); | - immutable borrow later used here For more information about this error, try `rustc --explain E0502`.
Rust 的报错信息很值得读,基本上有了报错信息,就能完全理解。
  • a 先被借给了不可变的 b,
  • 又需要借给可变的 c (let c: &mut i32 注意这里不是 c 可变,而是 c 指向的变量要求可变)
  • 事实上编译器在解析到 println!("b: {b}"); ,意识到 b 可能被修改(c可变),这是不允许的。
  • 这是 Rust 借用检测器的 “non-lexical lifetimes” 规则 (非词法作用域生命周期)
在Rust中,借用检查器负责验证代码中的借用规则,以确保内存安全和避免数据竞争。其中,生命周期(lifetimes)是指引用(reference)的有效范围,用于确定何时可以安全地访问和使用数据。
传统上,生命周期的计算是基于词法(lexical)作用域,即根据代码的语法结构和大括号的范围来确定引用的有效期。然而,Rust引入了“non-lexical lifetimes”这个特性,改变了生命周期计算的方式。
“non-lexical lifetimes”允许在某些情况下,不仅仅根据词法作用域来计算生命周期,而是基于更细粒度的控制流信息来确定引用的有效期。这意味着在一些复杂的代码结构中,引用的生命周期可以更准确地反映其实际的使用范围,而不仅仅受限于大括号的边界。
通过使用“non-lexical lifetimes”,Rust可以更精确地检测和验证代码中的借用规则,提供更大的灵活性和准确性,同时仍然保持内存安全。这个特性使得编写和理解复杂代码中的借用关系变得更加直观和简化。

不可变引用本身实现了 Copy,可变引用则是 Move

Rust 标准库中已经表明,不可变引用本身也是一种数据类型,这种数据类型是实现了 Copy 的,表示不可变引用是共享的;而可变引用因为是独占的,所以没有实现 Copy trait。
notion image
区别在于,把一个引用赋值给另一个变量的时候:
fn main() { let a = "hi".to_string(); let b = & a; let c = b; // 发生了指针拷贝 println!("{:?}", b); // ✅ }
因为 b 是一个不可变引用 &T ,相当于共享引用。所以执行 let c = b ,是触发了 Copy 行为的,具体是栈上的指针拷贝;所以 b 和 c 都可以继续使用。
fn main() { let mut a = "hi".to_string(); let b = &mut a; let c = b; // 发生了 Move,所有权转移 println!("{:?}", b); // 报错 ❌ }
由于 &mut T 可变引用本身没有实现 Copy ,所以当执行 let c = b; 实际上是将 b 的所有权转移给了 c,触发的是 Move 语义。所以 b 就不可用了。
 

Move 语义的本质

当转移所有权的时候,会发生 Move 行为;实际上编译器把它置为了未初始化状态:
fn main() { let mut a = "hi".to_string(); let b = a; // 产生了 Move 所有权 // 但是 a 不是立即释放,编译器只是把它置为了未初始化: // let mut a : String; // 相当于做了一次遮蔽定义 }
可以用下列代码验证:
fn main() { let mut a = "hi".to_string(); let b = a; // 产生了 Move 所有权 // 但是 a 不是立即释放,编译器只是把它置为了未初始化: // let mut a : String; // 相当于做了一次遮蔽定义 a = "hello".to_string(); println!("a is : {:?}", a); }
我们手动再给 a 做一次初始化,a 又可以被使用了。

生命周期

Rust 中的生命周期比较特殊,也比较难理解:
#[derive(Debug)] struct Point(i32, i32); fn left_most<'a>(p1: &'a Point, p2: &'a Point) -> &'a Point { if p1.0 < p2.0 { p1 } else { p2 } } fn main() { let p1: Point = Point(10, 10); let p2: Point = Point(20, 20); let p3: &Point = left_most(&p1, &p2); println!("p3: {p3:?}"); }
  • ‘a 是一种泛型参数,编译器会推断它在调用时的具体类型。
  • ‘a 是 rust 中的典型的默认写法
  • & ‘a Point 读作:借用(&) 一个 Point ,这个 Point 生命周期不能比 a 还短
如果稍加修改上边的代码:
#[derive(Debug)] struct Point(i32, i32); fn left_most<'a>(p1: &'a Point, p2: &'a Point) -> &'a Point { if p1.0 < p2.0 { p1 } else { p2 } } fn main() { let p1: Point = Point(10, 10); let p3: &Point; { let p2: Point = Point(20, 20); // error: p2 的生命周期没有 p3 长,报错 p3 = left_most(&p1, &p2); } println!("p3: {p3:?}"); }
就会编译报错,p2 只存活在它的作用域内 {}内 ,p3 在它的作用域外,生命周期比 p2 长。另外一个函数可以通过泛型声明多个生命周期规则:
fn left_most<'a, 'b>(p1: &'a Point, p2: &'a Point) -> &'b Point { if p1.0 < p2.0 { p1 } else { p2 } }
这里声明了 <'a, 'b> 两个生命周期规则,但是依然会编译报错。报错信息如下:
error: lifetime may not live long enough --> main.rs:5:22 | 4 | fn left_most<'a, 'b>(p1: &'a Point, p2: &'a Point) -> &'b Point { | -- -- lifetime `'b` defined here | | | lifetime `'a` defined here 5 | if p1.0 < p2.0 { p1 } else { p2 } | ^^ function was supposed to return data with lifetime `'b` but it is returning data with lifetime `'a` | = help: consider adding the following bound: `'a: 'b` error: could not compile `rust_main` due to previous error
简单的解释,'a, 'b 的生命周期谁长,并不明确。
另一种解释:
  • 函数的参数借用生命周期和返回值的借用生命周期不同
  • 返回值既然也是借用,那么它要么来自其中的一个函数参数,或者全局变量
  • 到底是哪个呢,编译器需要知道这一点,这样来保证调用测拿到的返回引用的生命周期不会比借用的参数还长。
借用生命周期小总结
从宏观上来看,函数就是 z = f(x, y),z 的值一定会被 x, y 影响。所以一个基本的安全保证就是,x, y 的存活时间一定要 ≥ z,才能保证 z 的值正常。
 

结构体的生命周期

如果一个数据类型存储了一个借用的数据,该数据类型就必须声明声明周期:
#[derive(Debug)] struct Highlight<'doc>(&'doc str); fn erase(text: String) { println!("Bye {text}!"); } fn main() { let text = String::from("The quick brown fox jumps over the lazy dog."); let fox = Highlight(&text[4..19]); let dog = Highlight(&text[35..43]); // erase(text); println!("{fox:?}"); println!("{dog:?}"); }
  • 和函数类似,Highlight 中的 str 的生命周期必须比任何一个 Highlight 实例都要长。
  • 如果 text 在 fox (或者 dog) 之前被消费掉,借用检查器就会报错。