Skip to content

“所有权”是Rust的核心概念,相较于与其他具有GC(garbage colloctor)的语言,Rust是无GC的。那么Rust是怎么保证内存安全的?取而代之的是“所有权”系统,它无需垃圾回收机制,就能保证内存安全。

所有权的目标

内存管理。准确来说:所有权系统是管理处于堆内存上的数据,追踪哪些代码在堆上使用了哪些数据,最大限度地减少堆数据的重复,并及时清理不再使用的数据。关于为什么是管理堆内存而不是栈内存,是因为栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。当向堆放入数据时,你要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针(pointer)。

所有权的规则

  • 对于在Rust中的任何值,都有一个称其“所有者”的变量。
  • 一个值在任一时刻都只能有且仅有一个所有者。
  • 变量(所有者)离开作用域后,值被清理。

变量作用域

1. 什么是作用域

通俗的讲就是一段代码在程序中的有效范围,这些代码一般被称为 “项”。作用域有两个重要的时间节点:

  • 变量出现在作用域之后
  • 变量在离开作用域之前

2. String类型

引入String类型帮助理解作用域。不同于Rust中其他已知固定大小的数据类型,如元组、数组、bool、char等,String是大小不固定且可变。因此其的存储位置在栈上,属于所有权系统的“管辖范围”。 String类型基于字符串字面量实现,字符串字面量就是被硬编进程序的部分,通常应用于文本常见。但并不是所有场景都适合,因为字符串字面量是不可变的,由此衍生了一种更灵活的字符串类型:String

创建String,from函数赋予字符串字面量String的命名空间。

rust
let s = String::from("hello");

同时String是可变的、不确定大小的数据量,因此它需要被分配到堆上,其在内存上的表达:内容存储在堆,在栈上存储一个指向堆上内容的指针;如下图所示:

当尝试将字符串字面量设置为可变,将会收到来自编译器的警告:warning: variable does not need to be mutable,而String类型则可变。

思考一个问题,为什么字符串字面量不可变,而String可变呢?这取决于两种类型在内存的处理上存在差异,要想进一步探讨这个问题,需要了解内存管理。

内存与分配

对于字面量值,得益于它的不可变性,编译时期就知道它的内容,因此它是高效且易用的。但对于在编译期不确定内容的文本,不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。

对于可变的String类型,为了维持一段可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

  • 运行时向内存分配器申请一定的内存
  • 在数据不再使用时,向内存管理器返回内存

第一步在创建String时,String::from已经完成向内存分配器申请内存。第二步对于有GC的语言,无需用户关心,GC会自动记录和标记数据的状态。而Rust是无GC的,这就需要一个方法,在数据不在使用时,释放相应的内存,这个方法就是drop。Rust的策略是在变量离开作用域时,就自动触发调用drop函数来释放内存。

rust
fn main() {
    {
        let s = String::from("hello"); // 从此处起,s 开始有效

        // 使用 s
    }                                  // 此作用域已结束,
                                       // s 不再有效
}

上述只是单个变量的简单引用场景,对于多个变量的复杂场景,存在更加复杂的数据交互。

1. 数据的交互方式一:移动(move)

对于已知固定大小的数据,移动就是拷贝一份“x”的值

rust
let x = 5;
let y = x;

在上述的示例中,声明变量x的值为5,然后声明变量y的值等于x即同样是5,所以x和y的大小都是已知的,因此他们都被压入了栈中。

对于运行时不确定其大小的数据,同样进行上述操作,所发生的事情便不完全相同了,s的存储方式是按照图一的方式,这时对s进行move操作,z的存储方式会如何改变?

rust
let s = String::from("hello,world");
let z = s;

s分为两部分分别存储在栈和堆上,存储在栈上的数据由一个指向堆的指针、容量、长度,存储在堆上的是内容数据。关于长度和容量并不是同样的概念,长度永远都是小于等于容量的。对于第二步let z = s,你可能认为会生成s的拷贝并绑定到z上,但事实并非如此。

已知大小数据的move操作后,再尝试使用被移动的数据会发生什么?

rust
fn main() {
    let x = 5;
    let y = x;
    println!("x = {}, x); //5
}

x的移动是在栈上进行的copy,因此move之后x仍旧有效。String类型move后再使用会发生什么?

rust
let s = String::from("hello,world");
let z = s;
println!("s = {}, s);) //

使用move之后的String类型数据,将会收到如下报错信息。

shell
error[E0382]: borrow of moved value: `s`
 --> src/main.rs:4:24
  |
2 |     let s = String::from("hello,world");
  |         - move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 |     let z = s;
  |             - value moved here
4 |     println!("s = {}", s); //
  |                        ^ value borrowed here after move

在讨论这个报错信息之前,先认识一下drop函数,drop函数是一个特殊的函数,当变量离开作用域时它被自动调用,来清理堆上分配的内存。对于String类型的move,当s和z都离开作用域时 由于z只是s的指针copy,真实数据只有一份在堆之上,这时两个drop都会尝试释放堆上的数据,这回产生一个叫做 二次释放(double free)的错误,两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。 String类型的move更像是其他语言中的浅拷贝,但实际rust为了避免二次释放的问题,被move的数据已经不再可用,因此收到上述报错信息。此时s和z在内存中的表现形式如下图所示:

2. 数据的交互方式二:克隆

如果想深拷贝String在堆上的数据,可以使用通用函数clone实现。

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

    println!("s1 = {}, s2 = {}", s1, s2);
}

使用clone函数后,s1将继续可用。思考一下存储在栈上的数据,是否发生移动?是否继续有效?

rust
fn main() {
    let x = 5;
    let y = x;

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

x未发生移动且继续生效,为什么栈上的数据无需克隆仍旧有效?

类似x这样的已知大小整型数据是存储在栈上的,这意味着拷贝是非常迅速的,或者深、浅拷贝是没有区别的。rust有一个 copy trait标注概念,类似这样的存储在栈上的类型, 如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait。

Rust中常见实现copy trait的类型:

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

所有权和函数

将值传递给函数在语义上与给变量赋值相似,可能会造成变量的移动或者复制。以下是一个所有权通过函数参数移动的示例:

rust
fn main() {
  let s = String::from("hello");  // s 进入作用域

  takes_ownership(s);             // s 的值移动到函数里 ...
                                  // ... 所以到这里不再有效

  let x = 5;                      // x 进入作用域

  makes_copy(x);                  // x 应该移动函数里,
                                  // 但 i32 是 Copy 的,所以在后面可继续使用 x

} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
  // 所以不会有特殊操作

fn takes_ownership(some_string: String) { // some_string 进入作用域
  println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
  println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。

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

    let (s2, len) = calculate_length(s1);
    
    
    println!("s1 = {}",s1);//value borrowed here after move
    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 返回字符串的长度

    (s, length)
}

在调用calculate_length函数后,再尝试访问s1,程序报错‘在移动后被借用’,原因是calculate_length函数并未返回其所有权并赋值给s1,calculate_length返回的是元组。