所有权(Ownership)是Rust中最独特的特性之一。正是因为所有权,Rust才能够在不需要GC的情况下保证内存安全。

我们常用的语言中,内存管理一般分为两种:

  • 自管理,谁分配的谁负责回收,如果没有回收,就会导致内存泄漏。如C、C++。
  • 由统一的垃圾回收器管理,GC负责追踪和管理已分配内存,如果内存不再被使用,则由GC进行回收。

Rust选择了第三种,内存会在拥有它的变量离开作用域时回收。因此,理解Rust的内存管理,就需要理解Rust中的所有权、借用规则、生命周期等概念。Rust会在编译过程中校验变量是否符合借用规则,从而保证运行时的内存安全,而且没有GC的额外开销。

在C++中,这种模式也被称作Resource Acquisition Is Initialization (RAII)

所有权基本规则#

所有权有三条基本规则:

  • Rust中每个值都有一个对应的变量称之为所有者(owner);
  • 同一时刻,一个值只能存在一个所有者;
  • 当所有者离开作用域时,其拥有的值会被丢弃。

copy和move#

因为同一时刻,一个值只能存在一个所有者,对于所有权的转让,Rust根据对象类型分为两种。

一种是Copy。即将值做拷贝,拷贝后,原变量拥有原值的所有权,新变量拥有新值的所有权,没有所有权的转移,两者可以同时使用。

let x = 5;
let y = x;
println!("x is {}, y is {}", x, y); // valid

支持Copy操作常见的类型通常是只存储在栈上的数据类型,如:

  • 所有的integer类型,如u32;
  • bool值;
  • char值;
  • 所有的float类型,如f64;
  • 元组,如果其包含的类型也都是支持Copy的。

这些类型都实现了Copy特型。如果一种类型实现了Copy特型,在分配新变量后,原变量仍是可用的。

另外一种是Move。对于类似String这种在栈上存储了元数据,实际数据存储在堆上的数据类型来说,将原变量赋给新变量实际上是将堆上数据的所有权转移给新变量,原变量在赋值后不再使用。

let s1 = String::from("hello!!!");
let s2 = s1;
println!("s2 is {}, s1 is {}", s2, s1); // invalid, s1 is no longer valid any more.

image-20210623093004432

代码中String类型实际转移过程如上图所示。"hello!!!"分配在堆上,在第2行后其所有权被转移给了s2,我们无法再通过s1访问"hello!!!"

那么如何实现类似于Copy的效果呢?对于String类型可以使用clone()方法。

let s1 = String::from("hello!!!");
let s2 = s1.clone();
println!("s2 is {}, s1 is {}", s2, s1); // valid, deep copy of s1.

clone()方法实际上对堆上数据也做了拷贝,如果数据对象很大,对于某些轻量的变量使用实际上是很浪费内存以及损耗性能的。如果需要规避这种损耗,就需要理解Rust中另外一个概念:Borrowing。

Borrowing#

Rust中的借用(Borrowing)有点类似于其它语言中的引用。结合所有权的概念,借用操作中并没有发生所有权的转移,而是一个指针指向了原变量。

image-20210623094449451

let s1 = String::from("hello!!!");
let s2 = &s1;
println!("s2 is {}, s1 is {}", s2, s1); // valid, s1 still owns the data.

从上面的图中我们很容易归纳出Rust借用操作中的第一个规则:

  • 引用的变量必须是有效的

从后文生命周期的角度来说,这条规则可以理解为:

  • 引用变量的生命周期必须在原变量的生命周期内

因为一旦原变量失效,引用变量就变成了一个悬置引用(Dangling Reference),极有可能造成不安全的内存访问。

借用操作另一个规则是:任何时间只能拥有如下两种引用中的一个:

  • 不限数量的不可变类型引用(immutable references)
  • 一个可变类型引用(mutable reference)

理解这条规则可以类比读写锁的概念。为变量添加了一个不可变类型引用相当于给变量添加了一把读锁,在读锁周期内,是可以为变量添加其它读锁的(多个不可变类型引用),但是写锁的添加必须等所有读锁释放(可变类型引用排他),并且同时只能拥有一把写锁(一个可变类型引用)。

这里还有一点要注意,可变类型引用必须保证原变量也是可变的。

let mut s1 = String::from("hello!!!");
{
    let s2 = &s1;
    let s3 = &s1;
    println!("s2 is {}, s3 is {}", s2, s3);
}

{
	let s4 = &mut s1;
	s4.make_ascii_uppercase();
	println!("s4 is {}", s4);
}

println!("s1 is {}", s1);

Lifetimes#

从所有权的概念中我们可以了解到,生命周期最主要的目的是防止悬置引用(dangling reference)。Rust编译器中部署了一个borrow checker,在编译的时候对变量的生命周期进行检查。检查的规则也很简单:

  • 引用变量的生命周期必须在原变量的生命周期内

由于这些校验都是在编译期间完成的,所以对运行时的性能没有影响。

生命周期声明#

通常使用'加上一个短字符表示一个生命周期,如下所示:

let s1 = String::from("hello world.");
{
    let &'a s2 = s1;
}

{
    let &'b mut s3 = s1;
}

函数的生命周期#

函数的生命周期表示如下:

fn foo<'a, 'b> (var1: &'a str, var2: &'b str) -> &'a str {
    // ...
    var1
}

首先在函数名后面用三角括号表示当前函数参数所用的生命周期,在每个参数后面标明当前的参数的生命周期。同样生命周期字符表示这些参数的生命周期是一致的。

结构体的生命周期#

如果结构体成员是一个引用,那么结构体成员也需要有生命周期,同时每个引用成员都需要标明生命周期。

结构体的生命周期表示如下:

struct HelloStruct<'a> {
    foo: &'a str,
}

结构体后面的生命周期注释表示结构体实例的生命周期不能超过其成员foo所引用的变量的生命周期。

方法的生命周期#

impl<'a> HelloStruct<'a> {
    fn level(&self) -> i32 {
        3
    }
}

结构体成员的生命周期也需要放在impl关键字后面。

如果有多个引用,self的生命周期怎么算?

隐式生命周期#

作用在函数或者方法参数的生命周期称作输入生命周期(input lifetimes),作用在返回值上的生命周期称作输出生命周期(output lifetimes)。

当没有显式指定生命周期时,Rust编译器会按照以下三条规则依次来确定参数的生命周期:

  1. 第一条规则作用于输入生命周期:每个引用参数都拥有自身的生命周期。换句话说,如果一个函数只有一个引用参数,那么它只会有一个生命周期。
  2. 第二条规则作用于输入生命周期:如果函数只有一个输入生命周期,那么这个生命周期会分配给所有拥有输出生命周期的参数。
  3. 第三条规则作用于输出生命周期:如果有多个输入生命周期,但因为是方法,其中一个是&self或者&mut self,那么self的生命周期将分配给所有游泳输出生命周期的参数。

如果Rust编译器在遍历了以上所有规则后,仍有不能确定生命周期的引用参数,那么编译器会抛出一个错误,停止编译。

合理利用这三条规则能使代码更加简洁、美观。

静态生命周期#

let s: &'static str = "I have a static lifetime.";

静态生命周期的变量存在于整个程序执行过程中,这些数据直接存在程序的二进制中。