Rust 的内存安全以及 move、copy、clone 语义

楔子

最近看到了两篇文章,觉得写得非常好。这里将其翻译一下,并结合自己的理解,对内容做一些补充。

文章链接如下:

https://hashrust.com/blog/memory-safety-in-rust-part-2/

https://hashrust.com/blog/moves-copies-and-clones-in-rust/

堆内存应该什么时候释放

前面在介绍栈与堆的时候说过,大小在编译期间即可确定、并且后续不会改变的对象,一般会在栈上申请内存;而大小在编译期间未知、或者后续会动态改变的对象,要在堆上为其分配内存。当然啦,虽然是在堆上分配的内存,但对象本身还是在栈上的,只是实际的数据在堆上,并通过指针指向。我们以动态数组为例:

let v: Vec<i32> = Vec::new();

我们知道动态数组是申请在堆上的,但动态数组我们应该分成两部分来看,一部分是动态数组这个结构本身,另一部分是数组里的元素。

任何一个可变的对象,都由两部分组成。栈上存储内存地址不会发生变化的结构本身,比如这里就是指针、容量、长度;然后栈上的指针指向一块堆区内存,这块堆内存负责存放实际的数据,当前就是动态数组里的元素。

栈上申请的内存不需要我们关心,操作系统会维护它。但堆就不一样了,堆内存的释放,则需要由编译器、解释器或者开发者调用某个函数去释放,因此这就涉及到了时机的问题,我们应该什么时候去释放堆内存呢。

显然要释放堆内存,那么就必须确保没有任何指向它的引用,否则就会出现悬空指针。我们举个例子:

fn main() {
    {
        let v = vec![1, 2, 3];
        // 此时堆内存显然还没有释放,也不能释放
        // 因为变量 v 在引用它
        println!("{:?}", v);
    } // 此处堆内存会被释放
    // 因为变量 v 离开了作用域,那么它在栈上的内存会被回收
    // 并且这一步是由操作系统完成的。既然栈上的内存被回收了
    // 那么就没有指向堆内存的引用了,因此堆内存也会被回收
}

但 Rust 怎么判断堆内存还有没有变量在引用呢?我们知道在 Python 里面每一个分配在堆上的对象都有一个引用计数,多一个引用,数值加 1;少一个引用,数值减 1;当数值为 0 时,会被销毁。但 Rust 是没有 GC 的,那它是怎么判断的呢?答案是不需要判断,因为 Rust 通过所有权机制保证了一块堆内存在同一时刻只能被一个变量所引用。

所有权

当你在 Rust 当中创建一个对象时,被赋值的变量将成为该对象的所有者。换句话说,变量拥有对象的所有权。

let v: Vec<i32> = Vec::new();

同一时刻,一个对象只能有一个所有者,这就保证了只有当所有者离开作用域才能释放堆内存,不会出现二次释放的问题。比如当前动态数组所使用的堆内存是否被释放,就看变量 v 是否离开了自己的作用域。

如果将变量 v 赋值给它其它变量呢?

fn main() {
    let v: Vec<i32> = Vec::new();
    let v1 = v;
}

栈上数据在传递的时候一律拷贝一份,但堆数据不会,于是就变成了如下这个样子:

显然此时对象(在堆上的内存)有了两个所有者,但 Rust 要求同一时刻,一个对象只能有一个所有者。或者说 Rust 要求堆内存在同一时刻只能被一个变量所引用,而现在有了两个变量。

于是 Rust 在面对这种情况时,会转移所有权,让 v1 成为对象的新所有者。而 v 就不允许再访问了,因为它已经失去了操作堆内存的权利(所有权)。

fn main() {
    let v = vec![1, 2, 3];
    let v1 = v;
    // 再使用变量 v 就会报错
    v;  // value used here after move
}

这里我们看到一个报错:value used here after move,意思是 value 在移动之后又被使用了,我们稍后会详细解释它。

但从开发者的角度来看,如果每次传递都要转移所有权的话,那么无疑会很麻烦。比如我想将 v 传到一个函数里面做一些事情,但当函数调用完毕之后 v 还能继续用。于是我们可以考虑引用。

引用

&v 表示对 v 的引用,我们可以通过 v 去访问动态数组,也可以通过 &v 去访问。而将引用赋值给别的变量这一行为就叫做借用,相当于给别的变量提供了访问的权利,但是又不会转移所有权。

fn main() {
    let v = vec![1, 2, 3];
    let v1 = &v;
    println!("{}", v.len());  // 3
    println!("{}", v1.len()); // 3
}

而引用可以赋给很多个变量,换句话说可以有多个借用者:

fn main() {
    let v = vec![1, 2, 3];
    let v1 = &v;
    let v2 = &v;
    println!("{}", v.len());  // 3
    println!("{}", v1.len()); // 3
    println!("{}", v2.len()); // 3
}

但借用者不能在所有者离开作用域之后(内存被释放),继续访问相应内存,否则会出现错误。

fn main() {
    let v1;
    {
        let v = vec![1, 2, 3];
        v1 = &v;
    }  // v 在此处会被释放
    // 但我们仍然操作了它的引用
    v1.len();
}

Rust 编译器足够聪明,它会确保借用者的使用范围不会超过所有者的存活时间。如果我们将上面的 v1.len() 给删掉,那么程序是没有问题的。

可变引用

&v 表示获取 v 的不可变引用,&mut v 表示获取 v 的可变引用。而将不可变引用赋值给别的变量叫做不可变借用,将可变引用赋值给别的变量叫做可变借用。

然后可变引用表达的含义是:允许通过引用去修改指向的值。所以获取变量的可变引用有一个前提,变量本身必须是可变的。

fn main() {
    let mut v = vec![1, 2, 3];
    let v1 = &mut v;
    v1.push(4);
    println!("{:?}", v1);
    // [1, 2, 3, 4]
}

一个变量的不可变引用可以有任意多个,但一个变量的可变引用在同一作用域当中只能有一个。并且在获得可变引用之后,就不能再获取不可变引用了。

不可变引用和可变引用的存在关系就类似于读锁和写锁,读锁可以有很多个,但写锁具有排他性。

move 和 copy

前面说了,将一个变量赋值给另一个变量的时候会转移所有权。

fn main() {
    let v = vec![1, 2, 3];
    let v1 = v;  // v1 成为了新的所有者
}

我们将 v 赋值给 v1 之后,会出现两个栈指针指向同一份堆内存,如下图所示:

所以此时会发生所有权的转移,以后这块堆内存就由变量 v1 来负责,v 再也无权染指,于是我们就说 v 发生了移动,它被移动到了 v1 上。并且 v 在移动之后,就不可以再使用了,当然它的引用也是如此,因为 Rust 不允许有两个栈指针指向同一块堆内存。

fn main() {
    let v = vec![1, 2, 3];
    // v 发生了移动
    let v1 = v;
    // 移动之后 v 不可以再使用
    // 但这里使用了,因此报错
    // value used here after move
    v;
}

相信你对这段报错的理解应该更加深刻了,然后还有一个与之相似的错误:

fn main() {
    let v = vec![1, 2, 3];
    let v1 = v;
    // 报错:value borrowed here after move
    v.len();
}

变量在移动之后,它和它的引用都不能再使用。但在调用 v.len() 的时候,会将 v 的引用赋值给 len 方法的第一个参数,因此相当于发生了借用。

  • value used here after move;
  • value borrowed here after move;

这两个错误是类似的,本质上都是使用了一个已经转移所有权的变量(或者变量的引用)。

fn main() {
    let v = vec![1, 2, 3];
    // v 发生了转移
    let v1 = v;
    // 使用了转移之后的变量
    // value used here after move
    let v2 = v;
    // 使用了转移之后的变量的引用
    // value borrowed here after move
    let v3 = &v;
}

另外除了赋值会导致变量移动之外,作为参数和返回值也是可以的,因为这个过程本质上也是赋值。

fn sum(v: Vec<i32>) -> i32 {
    let mut s = 0;
    for item in v {
        s += item;
    }
    s
}

fn main() {
    let v = vec![1, 2, 3];
    // v 作为了 sum 函数的参数
    // 相当于发生了移动
    println!("{}", sum(v));  // 6
    // 之后 v 不可以再被使用
    // 如果希望 v 依然保持有效
    // 那么 sum 的第一个参数的类型应该是 &Vec<i32>
}

然后让我们将类型做一下修改:

fn main() {
    /*
        let v = vec![1, 2, 3];
        let v1 = v;
        // 此处 v 不再有效
        println!("{:?}", v);
    */
    let v = 123;
    let v1 = v;
    println!("{} {}", v, v1)  // 123 123
}

咦,为啥将动态数组换成普通的 i32 整数,v 就能在赋值之后依旧保持有效呢?或者说,赋值语句为啥没有把 v 移动到 v1 上呢?

很简单,因为整数默认完全分配在栈上,根本不涉及到堆。而栈上的数据在传递的时候一律拷贝一份,所以 let v1 = v 之后两个变量拥有的数据都是各自独立的,我们没有理由去阻止 v 继续保持有效。

像这种不涉及到堆,传递之后彼此独立的类型称之为可 Copy 类型,或者说其实现了 Copy 这个 trait。所有的基础类型:像整数、浮点数、字符等都是可 Copy 的,而元组和数组则取决于内部的元素,如果内部的元素都是可 Copy 的,那么元组/数组也是可 Copy 的。

fn main() {
    // v 里的元素都是可 Copy 的
    // 那么 v 也是可 Copy 的
    let v = [1, 2, 3];
    let v1 = v;
    println!("{:?}", v);
    /*
    [1, 2, 3]
    */

    // v 里面的元素不是可 Copy 的
    // 所以 v 不是可 Copy 的
    let v = [String::from("古明地觉")];
    let v1 = v;
    // 赋值之后 v 不再有效,或者说发生了转移
    // 但我们却将它的引用传给了宏 println!
    // println!("{:?}", v);
    /*
    value borrowed here after move
    */
}

然后是 struct 和 enum,它们默认不是可 Copy 的,但可以为其派生相应的 trait。

// 让结构体可以通过 "{:?}" 打印
#[derive(Debug)]
// 让结构体是可 Copy 的
// 事实上这个三个 trait 可以写在一行
// #[derive(Degug, Clone, Copy)]
#[derive(Clone, Copy)]
struct Point {
    x: i32,
    y: i32
}

fn main() {
    let p = Point{x: 5, y: 6};
    let p1 = p;
    println!("{:?}", p);
    println!("{:?}", p1);
    /*
    Point { x: 5, y: 6 }
    Point { x: 5, y: 6 }
    */
}

这里会有人好奇,不是实现 Copy 吗,为啥把 Clone 也带上了。这个问题暂时先不考虑,以后再解释,只需要知道 Clone 是必须的即可。如果实现了 Copy trait,那么结构体或者枚举的每个成员都要是可 Copy 的,像下面这段代码就不合法。

#[derive(Clone, Copy)]
struct Point {
    x: Vec<i32>
}

x 是 Vec<i32> 类型,它没有实现 Copy。

clone

如果只拷贝栈数据、不拷贝堆数据,那么称之为浅拷贝;既拷贝栈数据、又拷贝堆数据,那么称之为深拷贝。

Rust 默认是浅拷贝,这对可 Copy 的类型来说是没问题的,因为它们的数据都在栈上。但像字符串、动态数组就不行了,它们内部的实际数据是存在堆上的,此时会发生变量的移动,或者说所有权的转移。

那么问题来了,可不可以将堆数据也拷贝一份呢?也就是进行深度拷贝,答案是可以的。

fn main() {
    let v = vec![1, 2, 3];
    let v1 = v.clone();
    println!("{:?}", v);
    println!("{:?}", v1);
    /*
    [1, 2, 3]
    [1, 2, 3]
    */
}

如果是 let v1 = v,那么是浅拷贝,而 v.clone() 则表示深拷贝,此时内存布局示意图如下:

像这种数据存储在堆上的类型,都有 clone 方法,或者说它们都实现了 Clone 这个 trait。举个例子:

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

// 实现了 Clone trait,便可调用 clone 方法
impl Clone for Point {
    fn clone(&self) -> Self {
        Point {
            x: self.x + 1,
            y: self.y + 1,
        }
    }
}

fn main() {
    let v = Point{x: 3, y: 4};
    let v1 = v.clone();
    println!("{:?}", v);
    println!("{:?}", v1);
    /*
    Point { x: 3, y: 4 }
    Point { x: 4, y: 5 }
    */
}

Rust 一切皆类型,并由 trait 掌握类型的行为逻辑。通过实现 Clone trait,此时的 Point 结构体也拥有了 clone 方法。

小结

因此要注意 Rust 当中的赋值语句,由于 Rust 默认是浅拷贝,如果数据在浅拷贝之后彼此独立,那么说明数据全部在栈上。而这样的类型实现了 Copy trait,比如整数类型、浮点型等等。

如果栈上存储的是指针,指针指向了一块堆内存,那么浅拷贝之后,就会有两个栈指针指向同一块堆内存。因此这时候就会出现变量的移动,用于赋值的变量在赋值之后就不能再用了,除非显示地调用 clone 方法进行深度拷贝。而这样类型实现了 Clone trait,比如字符串、动态数组。

posted @ 2023-10-17 17:18  古明地盆  阅读(215)  评论(0编辑  收藏  举报