Rust所有权规则概述

Rust的所有权(ownership)规则贯穿整个语言,几乎每行代码都涉及到所有权规则,因此需要对所有权规则非常熟悉才能更好地使用Rust。

Rust所有权规则可以总结为如下几句话:

  • Rust中的每个值都有一个被称为其所有者的变量(即:值的所有者是某个变量)
  • 值在任一时刻有且只有一个所有者
  • 当所有者(变量)离开作用域,这个值将被销毁

这里对第三点做一些补充性的解释,所有者离开作用域会导致值被销毁,这个过程实际上是调用一个名为drop的函数来销毁数据释放内存。在前文解释作用域规则时曾提到过,销毁的数据特指堆栈中的数据,如果变量绑定的值是全局内存区内的数据,则数据不会被销毁。

例如:

fn main(){
  {
    // 堆中String类型的字符串数据的所有者是变量s
    // 换句话说,变量s拥有堆中String字符串的所有权
    let mut s = String::from("hello");
  } // 跳出作用域,栈中的变量s将被销毁,其指向的堆
    // 中数据也被销毁,但全局内存区的字符串字面量仍被保留
}

Rust中数据的移动

在其他语言中,有深拷贝和浅拷贝的概念,浅拷贝描述的是只拷贝数据对象的引用,深拷贝描述的是根据引用递归到最终的数据并拷贝数据。

在Rust中没有深浅拷贝的概念,但有移动(move)、拷贝(copy)和克隆(clone)的概念。

看下面的赋值操作,在其他语言中这样赋值是正确的,但在Rust中这样的赋值很可能会报错。

fn main(){
  let s1 = String::from("hello");
  let s2 = s1;

  // 将报错error: borrow of moved value: `s1`
  println!("{},{}", s1, s2); 
}

上面的示例中,变量s1绑定了堆中String字符串数据,此时该数据的所有者是s1。

当执行let s2 = s1;时,将不会拷贝堆中数据赋值给s2,也不会像其他语言一样让变量s2也绑定堆中数据(即,不会拷贝堆数据的引用赋值给s2)。

因此,下图的内存引用方式不适用于Rust。

如果Rust采用这种内存引用方式,按照Rust的所有权规则,变量在跳出作用域后就销毁堆中对应数据,那么在s1和s2离开作用域时会导致二次释放同一段堆内存,这会导致内存污染。

Rust采用非常直接的方式,当执行let s2 = s1;时,直接让s1无效(s1仍然存在,只是变成未初始化变量,Rust不允许使用未初始化变量,可重新为其赋值),而是只让s2绑定堆内存的数据。也就是将s1移动到s2,也称为堆数据的所有权从s1移给s2

如图(注意,这个图是错的,但便于此处对移动这个概念的理解,稍后解释为什么):

所有权移动后修改数据

定义变量的时候,加上mut表示变量可修改。当发生所有权转移时,后拥有所有权的变量也可以加上mut


#![allow(unused)]
fn main() {
let mut x = String::from("hello");

// x将所有权转移给y,但y无法修改字符串
let y = x;   
// y.push('C');  // 本行报错

let a = String::from("hello");
// 虽然a无法修改字符串,但转移所有权后,b可修改字符串
let mut b = a; 
b.push('C');   // 本行不报错
}

移动真的只是移动吗?

很多人会误解【移动】这个词,认为移动的效率比拷贝的效率更高。

比如下面的示例:


#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1;
}

上面已经分析过,堆中字符串数据的所有权会从变量s1转移到变量s2,很多人会认为这个过程是类似于按引用转移的过程,即将栈中该字符串数据的胖指针从s1转移到了s2,从而使得s1丢失指针,而s2拥有堆中字符串数据的所有权。

尽管这样的行为才是更高效的行为,但Rust并不是这样做的。

在发生所有权转移时,Rust会拷贝数据(直接内存拷贝),然后将拷贝得到的数据绑定到新变量。原始数据将被销毁,原始变量将回到未初始化状态,但注意,转移所有权时销毁原始数据时,不会调用用于析构的drop方法。

比如上面的示例中,当执行let s2 = s1;时,会将堆中字符串重新拷贝一次,然后将拷贝得到的新字符串赋值给变量s2,同时会销毁原始字符串数据,使得变量s1回到未初始化状态。

可通过如下代码验证该结论:

fn main(){
  let s1 = String::from("junmajinlong");
  println!("{:p}", &s1);  // 0x64600ff898

  // 所有权转移,但地址也发生了改变
  let s2 = s1;
  println!("{:p}", &s2);  // 0x64600ff900
}

因此,所有权的移动,不仅仅是所有权的移动,也是数据的移动,理解这一点很重要。例如,所有权移动后,由于数据也从原来的内存位置处被移走,使得原来指向它的引用将指向一个被释放的内存地址,即这个引用将变成悬垂指针。

Move不仅发生在变量赋值过程中,在函数传参、函数返回数据时也会Move,因此,如果将一个大数据量的变量作为参数传递给函数,是否会让效率很低下?

按照上面的结论来说,确实如此。但Rust编译器会对Move语义的行为做出一些优化,简单来说,当数据量较大且不会引起程序正确性问题时,它会传递数据的指针而非内存拷贝。

另外,对于胖指针类型的变量,即使发生了拷贝,其性能也不差,因为拷贝的只是它的胖指针部分。

总之,Move虽然发生了内存【拷贝】,但它的性能并不会太受影响。

此处部分结论参考:https://stackoverflow.com/questions/30288782/what-are-move-semantics-in-rust

克隆数据

虽然所有权转移时,Rust也会拷贝实际的数据,但所有权转移会导致原始变量变回未初始化状态而无法使用。

可以使用clone()方法手动拷贝变量绑定的数据,同时不会让原始变量变回未初始化状态。

fn main(){
  let s1 = String::from("hello");
  // 克隆s1,克隆之后,变量s1仍然绑定原始数据
  let s2 = s1.clone();
  println!("{},{}", s1, s2);
}

但不是所有数据类型都可以进行克隆,只有那些实现了Clone Trait的类型才可以进行克隆(Trait类似于面向对象语言中的接口,如果不了解可先不管Trait是什么),常见的数据类型都已经实现了Clone,因此它们可以直接使用clone()来克隆。

对于那些没有实现Clone Trait的自定义类型,需要手动实现Clone Trait。在自定义类型之前加上#[derive(Copy, Clone)]即可。例如:


#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
struct Abc(i32, i32)
}

这样Abc类型的值就可以使用clone()方法进行克隆。

Rust栈中数据的拷贝

前面介绍了所有权的转移,所有权转移后,原始变量会变回未初始化状态而无法使用。一般情况下,所有权转移是针对堆中数据的,比如String类型的数据存放在堆中(但它的胖指针存放在栈中)。

于栈而言,其保存的都是原始的数据值或各种类型的指针,它们的大小都是固定的。当栈中数据的所有权发生转移时,情况有所不同:直接拷贝栈中数据,且不会让原始变量丢失原数据的所有权

分析如下代码:

fn main(){
  // s1绑定了栈中的3,注意数值3是直接存储在栈中的
  let s1 = 3;
  let s2 = s1; // 拷贝s1的值给s2,现在栈中有两个3

  println!("{}, {}", s1, s2); // 3, 3
  println!("{:p}, {:p}", &s1, &s2);
  // 0xb36b37fa80, 0xb36b37fa84
}

变量s1绑定了栈中的数据3,然后拷贝了s1并赋值给s2,因为s1绑定的是栈中数据,所以所有权转移时实际上是拷贝了栈中的3,使得栈中有两个3,并且s1和s2都保存各自的3。

因此,如果变量s1是保存在栈中数据,那么let s2 = s1;等价于let s2 = s1.clone();

为什么栈中的数据在发生所有权转移时不会让原始变量变回未初始化状态?这是因为保存在栈中的数据类型实现了Copy这个Trait

比如所有整数类型、浮点数类型、指针类型等都实现了Copy Trait,它们在发生所有权转移时,都不会让原始变量丢失原始数据的所有权,相当于是克隆了栈中数据。

实际上,即使是保存在堆中的数据类型,只要它实现了Copy Trait,那么它的值在发生所有权转移时,也一样不会让原变量变回未初始化状态。

那么什么类型是Copy的呢?这需要查看该类型的文档来确认。不过作为通用的规则,保存在栈中的数据类型都是实现了Copy Trait的。包括但不限于:

  • 所有整数类型,比如u32
  • 所有浮点数类型,比如f64
  • 布尔类型,bool,它的值是true和false
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都是Copy的时候。比如(i32, i32)是Copy的,但(i32, String)不是
  • 指针类型或引用类型

对于那些没有实现Copy的自定义类型,可以手动去实现Copy,方式很简单:


#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
struct Abc(i32, i32)
}

函数参数和返回值的所有权移动

函数参数类似于变量赋值,在调用函数时,会将所有权移动给函数参数

函数返回时,返回值的所有权从函数内移动到函数外变量

例如:

fn main(){
  let s1 = String::from("hello");
  
  // 所有权从s1移动到f1的参数
  // 然后f1返回值的所有权移动给s2
  let s2 = f1(s1); 
  // 注意,println!()不会转移参数s2的所有权
  println!("{}", s2);

  let x = 4; // x绑定的是栈中数据(是Copy的)
  f2(x);     // 没有移动所有权,而是拷贝一份给f2参数
}  // 首先x跳出作用域,其对应栈数据出栈,
   // 然后s2跳出作用域,并释放对应堆内存数据,
   // 最后s1跳出作用域,s1没有所有权,所以没有任何其他影响

fn f1(s: String) -> String {
  let ss = String::from("world"); 
  println!("{},{}", s,ss);
  s  // 返回值s绑定的堆数据的所有权移动到函数外
}    // ss跳出作用域,其对应堆中数据被释放

fn f2(i: i32){
  println!("{}",i);
}   // i跳出作用域,i绑定的栈数据出栈

很多时候,变量传参之后丢失所有权是非常不方便的,这意味着函数调用之后,原变量就不可用了。另一方面,所有权转移时也拷贝了数据,因此传递大量数据时的效率也比较低。

为了解决这个问题,可以将变量的引用传递给参数。引用是保存在栈中的,它实现了Copy Trait,因此在传递引用时,所有权转移的过程实际上是拷贝了引用,这样不会丢失原变量的所有权,效率也更高。