聊一聊 Rust 的智能指针 Box<T>

智能指针 Box

在 Rust 里面,像整数、浮点数、数组等结构默认都是分配在栈上的,至于结构体和枚举,默认也在栈上分配。说到这里需要先解释一下容易陷入的误区,在介绍所有权的时候我们说过:如果值在栈上分配,不涉及到堆,那么在变量赋值的时候会拷贝一份。由于是拷贝,那么前后两个变量彼此独立,互不影响。

对于整数来说,显然没有任何问题,但结构体就不行了,告诉我们由于值发生了移动,s1 不能再用了。不是说结构体实例是分配在栈上的吗,那应该拷贝一份啊,拷贝之后两个变量 s1 和 s2 应该是独立的。因此这里需要纠正一点,Rust 是否进行拷贝跟变量的值是否完全分配在栈上无关,而是取决于该变量是否实现了 Copy 这个 trait。当然,如果变量想实现 Copy,那么值完全分配在栈上是前提。

如果实现了,那么就进行拷贝,拷贝之后两个变量彼此独立,比如代码中的 n1 和 n2。如果没有实现,那么就发生移动,比如代码中的 s1 和 s2,移动之后 s1 就不能用了。

所以尽管这里的结构体实例分配在栈上,没有涉及到堆,但它没有实现 Copy,因此就不能拷贝。之所以没有实现 Copy,是因为结构体如果是可 Copy 的,那么内部的每一个字段必须也是可 Copy 的,但是 Rust 并不知道你的结构体字段类型。所以如果你能保证每个字段都是可 Copy,那么可以通过 derive 注解告诉编译器,这个结构体是可 Copy 的。

// 通过注解指定结构体是可 Copy 的,但前提是每个字段也必须可 Copy
// 但不管结构体是否可 Copy,它的实例默认都是在栈上分配内存
#[derive(Copy, Clone, Debug)]
struct MyStruct {
    value: i32,
}

fn main() {
    let n1 = 123;
    let n2 = n1;
    println!("{} {}", n1, n2);
    // 123 123

    let s1 = MyStruct{value: 123};
    let s2 = s1;
    println!("{:?} {:?}", s1, s2);
    // MyStruct { value: 123 } MyStruct { value: 123 }
}

因此 Rust 的所有权系统不仅仅关乎堆内存,它用于管理所有资源的生命周期和可变性,无论这些资源是否在堆上。引入所有权是为了确保内存安全、防止数据竞争,以及无需垃圾收集器即可自动管理内存。要达到这些目的,Rust 为每个值引入了一个明确的所有者,并通过所有权、引用和借用规则来管理这些值的生命周期和可变性。

当所有权发生转移时,我们就说变量的值被移动了。如果变量的值移动之后还使用它,那么会报错:value used here after move;如果变量的值移动之后还获取它的引用借给别人,那么也会报错:value borrowed here after move。

但即便如此,由于大部分完全分配在栈上的数据都是可 Copy 的,为了方便理解,我们仍可以把所有权简单理解为变量操作堆内存的权利。比如 s1 的数据涉及到堆,那么 let s2 = s1 之后,由于两个变量内部的指针都指向同一份堆内存,所以 Rust 会让 s1 失去操作堆内存的权利,从而将所有权转移到 s2 上面,也就是 s1 的值发生了移动,后续 s1 就不可以再使用了。

以上就是我们之前对所有权的理解,这种理解方式很直观。但现在我们知道,即使不涉及到堆,变量的值仍然可以发生移动,只要它没有明确地实现 Copy trait。一旦变量的值移动了,后续就不能再使用它。

变量如果是可 Copy 的,那么它的值一定完全分配在栈上。但变量的值完全分配在栈上,则不代表变量一定是可 Copy 的(比如结构体,需要通过注解告诉编译器,当然前提是所有字段都是可 Copy 的)。

好啦,目前算是对学过的内容做了一个回顾和补充,然后来聊一聊智能指针。我们知道,整数、浮点数等类型的变量,默认都在栈上分配内存,那么可不可以改成堆上分配呢?答案是可以的,我们可以把值装在箱子里,让它在堆上分配。当箱子离开作用域时,它的析构函数会被调用,内部的对象会被销毁,堆上的内存也会被释放。

而箱子可以通过 Box::new 来创建,该方法会返回一个 Box<T> 类型的指针,该指针便是智能指针,指向了堆上分配的 T 类型的值。

fn main() {
    // 整数分配在栈上
    let v1 = 123;
    // 整数分配在堆上,返回智能指针
    // v2 的类型为 Box<i32>
    let v2 = Box::new(123);
    println!("{}", v1);  // 123
    // 打印 v2 和 *v2 是一样的
    println!("{}", *v2);  // 123
    println!("{}", v2);  // 123

    // 但在计算的时候,需要解引用
    println!("{}", *v2 + 3); // 126
}

然后要注意的是,上面在创建 v2 的时候,值 123 并不是直接分配在堆上的。123 仍然是先分配在栈上,然后再拷贝到堆上。

fn main() {
    let v1 = [1, 2, 3, 4];
    // 将 v1 拷贝到堆上,原本的 v1 不受影响
    let v2 = Box::new(v1);
    println!("{:?}", v1);
    println!("{:?}", v2);
    println!("{:?}", *v2);
    /*
    [1, 2, 3, 4]
    [1, 2, 3, 4]
    [1, 2, 3, 4]
    */
}

直接写成 let v2 = Box::new([1, 2, 3, 4]) 和上面是等价的,数组仍然是先分配到栈上,然后拷贝到堆上。

再来看一下 v1,它的值是可 Copy 的,所以创建 v2 的时候直接拷贝一份。但如果它不是可 Copy 的呢?

fn main() {
    // v1 是 String 类型
    let v1 = "hello".to_string();
    let v2 = Box::new(v1);
    println!("{:?}", v2);
    println!("{:?}", *v2);
    /*
    "hello"
    "hello"
    */
}

v2 的打印没有问题,但 v1 已经不能再用了,它发生了移动,失去了所有权。我们回顾一下 String 的结构:

String 本身是分配在栈上的,然后实际的文本分配在堆上,由内部指针指向。当创建 v2 的时候,会将 String 也拷贝一份到堆上,但是堆区的文本并没有拷贝,因为 Rust 默认不会深度拷贝。

此时 String 一个分配在栈上,一个分配在堆上,但它们内部的指针都引用了同一份堆内存,所以此时会转移所有权。但要注意 v2,它是一个指针,这个指针仍然是分配在栈上的。

那么 Box 这种将值装箱的操作有什么作用呢?首先它可以用于链表、树这种递归结构,举个例子。

// 实现一个链表,如果是在 C 里面的话
/*
struct Node {
    int val;
    struct Node *next
}
*/
// 但在 Rust 里面应该这么做
struct Node {
    val: i32,
    next: Option<Box<Node>>
}

fn main() {
    let n1 = Node{val: 1, next: None};
    let n2 = Node{val: 2, next: Some(Box::new(n1))};
    let n3 = Node{val: 3, next: Some(Box::new(n2))};
    let n4 = Node{val: 4, next: Some(Box::new(n3))};
    let n5 = Node{val: 5, next: Some(Box::new(n4))};
    let mut header = Some(Box::new(n5));
    // 从头结点开始遍历
    while let Some(n) = header {
        println!("{}", n.val);
        header = n.next;
    }
    /*
    5
    4
    3
    2
    1
    */
}

在 Rust 里面实现一个链表或者树的时候,它的节点就是 Option<Box<Node>> 类型,而不是像 C、Go 那样。我们以牛客网上的算法题为例:

这是牛客网提前定义好的结构体,再来看一下方法:

不难发现,Rust 在此刻就显得有些笨拙了。首先必须在编译阶段清楚地知道每个类型的大小,因此要通过 Box<Node> 表示它是个指针。但 Rust 的智能指针不能为空,或者说 Rust 没有空指针,于是还需要套上一层 Option,否则链表就要无限下去。

如果是 Go 的话:

显然此时就优雅多了。因此用 Go 处理链表相关的问题非常轻松,但用 Rust 去写的话就很遭罪了,比如删除节点等等。主要是链表这种数据结构天生和 Rust 的借用规则是冲突的,一个链表节点在操作的时候同时被多个指针指向和修改是很常见的事情,更别说双向链表这种自带循环指向的数据了。

最简单的做法还是通过 unsafe 实现,标准库都是 unsafe 实现的,甚至很多时候不用 unsafe 根本就做不了。很多人在刚学 Rust 的时候,看到实现一个链表居然这么费劲,直接就被劝退了,其实大可不必,用 Rust 实现链表确实不是件容易的事。

Deref trait

然后我们来说一个 trait,叫 Deref。我们之前对一个变量解引用的时候,一般都是先通过 & 获取引用,然后再通过 * 解引用。但对于 Box<T> 类型的变量来说,直接就可以解引用,就是因为它实现了 Deref trait,举个例子。

use std::ops::Deref;
// 定义一个结构体
struct MyBox<T> {
    x: T
}

impl<T> MyBox<T> {
    fn new(x: T) -> Self {
        MyBox{x: x}
    }
}

// 实现 Deref trait
impl<T> Deref for MyBox<T> {
    type Target = T;
    // 实现 deref 方法,返回一个引用
    fn deref(&self) -> &T {
        &self.x
    }
}

fn main() {
    // 此时的 m 就是 MyBox<i32> 类型
    let m = MyBox::new(123);
    // 对 m 解引用,会调用 deref 方法
    println!("{}", *m);  // 123
    // *m 等价于 *(m.deref())
    println!("{}", *(m.deref()));  // 123
}

注意:只有在当前 crate 中定义的结构才可以实现 Deref trait,比如我们不能对 i32 实现 Deref trait。

所以总结一下,Box<T> 是 Rust 里面非常重要的一个类型,但它并不是一个引用,而是一个智能指针,用于在堆上分配值。并且保证当内存不再使用时自动释放,遵循 Rust 的所有权和生命周期规则。当 Box<T> 离开作用域时,它所指向的堆内存会被自动清理,这就消除了手动管理内存的需要,减少了内存泄漏的风险。

尽管 Box<T> 表现得像引用(因为实现了 Deref trait,所以可以通过解引用操作符 * 来访问其指向的数据),但它是一个完整的所有权类型。这意味着当你将 Box<T> 从一个变量移动到另一个变量时,其所有权(以及对堆上数据的控制)会改变。与此同时,引用只是借用另一个值的所有权,而不持有它。

总结来说,Box<T> 是一个在堆上分配的智能指针,它不仅包含指向堆分配值的指针,还拥有这个值的所有权,并在其生命周期结束时自动释放这部分内存。它允许你通过值的语义管理堆上的数据,而不是引用的语义。

还没结束,基于智能指针,我们再将以前的内容复习一遍。

Drop trait

再来看看 Drop trait,它定义了变量离开作用域时的行为,一般用于文件、网络资源的释放。

struct SomePointer {
    data: String
}
// 为 SomePointer 实现 Drop trait
impl Drop for SomePointer {
    fn drop(&mut self){
        println!("变量离开作用域时执行,data: {}", self.data)
    }
}

fn main() {
    {
        let m = SomePointer{data: "你好".to_string()};
    }
    println!("......")
    /*
    变量离开作用域时执行,data: 你好
    ......
    */
}

Rust 一切皆类型,由 trait 控制类型的行为逻辑,实现了哪些 trait,便具有哪些行为逻辑。事实上 Drop 这个 trait 我们一开始就见过,那些数据在堆上的类型便实现了该 trait。如果一个变量还没有离开作用域,我们就想释放它,该怎么做呢?

struct SomePointer {
    data: String
}

impl Drop for SomePointer {
    fn drop(&mut self){
        println!("变量离开作用域时执行,data: {}", self.data)
    }
}

fn main() {
    let m = SomePointer{data: "你好".to_string()};
    // 我们不可以调用 m.drop(),而是需要使用 drop 函数
    // 此时 m 就被释放了
    drop(m);
    println!("......");
    /*
    变量离开作用域时执行,data: 你好
    ......
    */
}

后续如果再使用 m 就会报错:value used here after move。整个过程应该不复杂,但是注意:我们不能对一个变量多次使用 drop。

RALL

Rust 强制实行 RAII(Resource Acquisition Is Initialization,资源获取即初始化),所以任何对象在离开作用域时,它的析构函数(destructor)就被调用,然后它占有的资源就被释放。这种行为避免了资源泄漏(resource leak),所以你再也不用手动释放内存或者担心内存泄漏(memory leak)。

fn create_box() {
    // 在堆上分配一个整型数据
    let _box1 = Box::new(3i32);

    // `_box1` 在这里被销毁,内存得到释放,因为它实现了 Drop trait
}

fn main() {
    // 在堆上分配一个整型数据
    let _box2 = Box::new(5i32);

    // 嵌套作用域:
    {
        // 在堆上分配一个整型数据
        let _box3 = Box::new(4i32);

        // `_box3` 在这里被销毁,内存得到释放
    }

    // 创建一大堆 box,并且不需要手动释放内存!
    for _ in 0u32..1_000 {
        create_box();
    }

    // `_box2` 在这里被销毁,内存得到释放
}

所有权和移动

因为变量要负责释放它们拥有的资源(值),所以一个资源只能拥有一个所有者,这也防止了资源的重复释放。注意:并非所有变量都拥有资源,例如引用。

在进行赋值或传递函数参数,比如 let x = y、foo(x) 的时候,所有权(ownership)会发生转移。按照 Rust 的说法,这被称为资源的移动(move)。在移动资源之后,原来的所有者不能再被使用,这可避免悬空指针(dangling pointer)的产生。

// 此函数取得堆分配的内存的所有权
fn destroy_box(c: Box<i32>) {
    println!("Destroying a box that contains {}", c);

    // `c` 被销毁且内存得到释放
}

fn main() {
    // 栈分配的整型
    let x = 5u32;

    // 将 `x` 复制到 `y`,因为是可 Copy 的,所以不存在资源移动,因为会拷贝一份
    let y = x;

    // 两个值各自都可以使用
    println!("x is {}, and y is {}", x, y);

    // `a` 是一个指向堆分配的整数的指针
    let a = Box::new(5i32);

    println!("a contains: {}", a);

    // 移动 `a` 到 `b`
    let b = a;
    // 把 `a` 的指针地址(而非数据)复制到 `b`
    // 现在两者都指向同一个堆分配的数据,但是现在是 `b` 拥有它。

    // 报错!`a` 不能访问数据,因为它不再拥有那部分堆上的内存。
    println!("a contains: {}", a);

    // 此函数从 `b` 中取得堆分配的内存的所有权
    destroy_box(b);

    // 此时堆内存已经被释放,再操作变量 b 会导致解引用已释放的内存,而这是编译器禁止的。
    // 报错!和前面出错的原因一样,value borrowed here after move
    // 值都没了,还在使用它的引用
    println!("b contains: {}", b);
}

然后当所有权转移时,数据的可变性可能发生改变。

fn main() {
    let immutable_box = Box::new(5u32);

    println!("immutable_box contains {}", immutable_box);  // immutable_box contains 5
    println!("{}", *immutable_box == 5);  // true

    // 可变性错误
    // *immutable_box = 4;

    // 移动 box,改变所有权(和可变性)
    let mut mutable_box = immutable_box;

    println!("mutable_box contains {}", mutable_box);  // mutable_box contains 5

    // 修改 box 的内容
    *mutable_box = 4;

    println!("mutable_box now contains {}", mutable_box);  // mutable_box now contains 4
}

变量的值在移动时,也可以部分移动,举个例子。

#[derive(Debug)]
struct Person {
    name: String,
    age: Box<u8>,
}

fn main() {
    let p = Person{name: "古明地觉".to_string(), age: Box::new(17)};
    let name = p.name;
    println!("{}", name);  // 古明地觉
    // 变量 p 的 name 字段的值发生了移动,所以 p 不可以再使用
    // 准确的说,在使用 p 的时候,不能访问值发生移动的字段
    // println!("{:?}", p);
    
    // 这里访问的是 age 字段,所以没有问题
    println!("{}", p.age);  // 17
}

借用

多数情况下,我们更希望能访问数据,同时不取得其所有权。为实现这点,Rust 使用了借用(borrowing)机制。对象可以通过引用(&T)来传递,从而取代通过值(T)来传递。编译器(通过借用检查)静态地保证了引用总是指向有效的对象,也就是说,当存在引用指向一个对象时,该对象不能被销毁。

// 此函数取得一个 box 的所有权并销毁它
fn eat_box_i32(boxed_i32: Box<i32>) {
    println!("Destroying box that contains {}", boxed_i32);
}

// 此函数借用了一个 i32 类型
fn borrow_i32(borrowed_i32: &i32) {
    println!("This int is: {}", borrowed_i32);
}

fn main() {
    // 创建一个装箱的 i32 类型,以及一个存在栈中的 i32 类型。
    let boxed_i32 = Box::new(5_i32);
    let stacked_i32 = 6_i32;

    // 借用了 box 的内容,但没有取得所有权,所以 box 的内容之后可以再次借用
    borrow_i32(&boxed_i32);  // This int is: 5
    borrow_i32(&stacked_i32);  // This int is: 6

    {
        // 取得一个对 box 中数据的引用
        let _ref_to_i32: &i32 = &boxed_i32;

        // 报错!`boxed_i32` 里面的值后续在作用域中还要被借用,所以不能将其销毁。
        // eat_box_i32(boxed_i32);

        //如果此处不借用,则在上一行的代码中,eat_box_i32(boxed_i32) 可以将 `boxed_i32` 销毁
        borrow_i32(_ref_to_i32);  // This int is: 5
        // `_ref_to_i32` 离开作用域且不再被借用
    }

    // `boxed_i32` 现在可以将所有权交给 `eat_i32` 并被销毁。
    // 而能够销毁是因为已经不存在对 `boxed_i32` 的引用
    eat_box_i32(boxed_i32);  // Destroying box that contains 5
}

看上面的代码估计有人会好奇,对 boxed_i32 取引用之后,类型难道不应该是 &Box<i32> 吗?为啥可以是 &i32 呢?因为 Rust 提供了自动解引用的特性,由于 Box<T> 实现了 Deref trait,所以 Rust 会自动将它的引用转换为 Deref trait 的返回值的引用。

fn main() {
    // Box<T> 表示将类型 T 的值在堆上分配,并返回它的指针,类型为 Box<T>
    let boxed_i32 = Box::new(123i32);
    // 如果对 boxed_i32 解引用,那么会将箱子里面的值拷贝一份,也就是将 i32 拷贝一份
    println!("{}", *boxed_i32);  // 123
    // Box<T> 在堆上分配,所以执行 let boxed_i32_new = boxed_i32 之后,boxed_i32 就不能再用了
    // let boxed_i32_new = boxed_i32;

    // 但我们可以获取 Box<T> 的引用,它的类型为 &Box<i32>
    let boxed_i32_ref = &boxed_i32;
    // *boxed_i32_ref 显然是 Box<i32>,**boxed_i32_ref 就是 i32
    println!("{}", **boxed_i32_ref == 123);  // true

    // 此时 Rust 会自动解引用,下面两种方式是等价的
    let boxed_i32_ref1: &i32 = &boxed_i32;
    let boxed_i32_ref2: &i32 = &*boxed_i32;
    println!("{} {}", *boxed_i32_ref1 == 123, *boxed_i32_ref2 == 123)  // true true
}

在获取引用的时候,除了可以通过 &,还可以使用 ref 关键字。

fn main() {
    let boxed_string = Box::new("Hello".to_string());
    // 等价于 let boxed_string_ref = &boxed_string
    // boxed_string_ref 的类型为 &Box<String>
    let ref boxed_string_ref = boxed_string;
    // 第一次解引用得到 Box<String>,第二次解引用得到 String
    println!("{}", **boxed_string_ref);  // Hello

    // 注意:下面这行代码是有问题的,它表示将 Box<T> 里面的 T 拷贝一份
    // 所以 Rust 要求 T 必须是可 Copy 的,否则智能指针就没法用了
    // 而这里的 T 是 String,不是可 Copy 的,因此报错
}

当然,ref 关键字也可以用于元组和结构体的解构。

fn main() {
    let mut tpl = (Box::new(123), 222);
    // 先查看一下地址
    println!("{:p}", &tpl.0);  // 0x30d556118
    // 相当于 let (first, second) = (&mut tpl.0, &mut tpl.1)
    let (ref mut first, ref mut second) = tpl;
    **first = 666;
    *second = 333;
    println!("{:?}", tpl);  // (666, 333)
    // 再查看一下地址
    println!("{:p}", &tpl.0);  // 0x30d556118

    // 我们看到基于 **first 这种方式将值修改之后,tpl.0 的地址并没有发生改变
    // 所以相当于直接将堆上的数据给修改了,而没有改变 Box 本身的位置或重新分配内存
    // 当然 *first = Box(666) 也是同理,都只改变具体的堆数据,智能指针不受影响
}

以上就是 Rust 的智能指针,可以再好好理解一下。

posted @ 2023-10-25 16:41  古明地盆  阅读(1039)  评论(0编辑  收藏  举报