进阶篇-智能指针

  • 木头
  • 更新于 2023-03-16 11:34
  • 阅读 2247

Box,Deref和Drop trait,Rc<T>和Arc<T>,Cell<T>和RefCell<T>

指针 (pointer)是一个包含内存地址的变量。这个地址指向一些其它的数据。Rust 中最常见的指针是引用(reference)。引用以&符号为标志并借用了他们所指向的值。

另一方面,智能指针(smart pointers)是一类数据结构,它们表现类似于指针,但是也拥有额外的元数据,最明显的,它们拥有一个引用计数。引用计数记录智能指针总共有多少个所有者,并且当没有任何所有者时清除数据。

智能指针通常使用结构体实现。智能指针区别于常规结构体的显著特征在于其实现了 Deref(解引用)和 Drop(清理) trait。

  1. Deref trait允许智能指针结构体实例表现的像引用一样,这样就可以编写即用于引用,又用于智能指针的代码。
  2. Drop trait 允许我们自定义当智能指针离开作用域时运行的代码。

考虑到智能指针是一个在 Rust经常被使用的通用设计模式,本章并不会覆盖所有现存的智能指针。很多库都有自己的智能指针而你也可以编写属于你自己的智能指针。这里将会讲到的是来自标准库中最常用的一些:

  • Box&lt;T>,用于在堆上分配值
  • Rc&lt;T>,一个不可变的引用计数类型,用于同一线程内部其数据可以有多个所有者。Arc&lt;T>,一个不可变的引用计数类型,用于多线程内部其数据可以有多个所有者。
  • Cell&lt;T>,只能用于 T 实现了 Copy 的情况。RefCell&lt;T>,是一个在运行时而不是在编译时执行借用规则的类型。

Box <T>

最简单直接的智能指针是box,其类型是 Box&lt;T>box允许你将一个值放在堆上而不是栈上。留在栈上的则是指向堆数据的指针。除了数据被储存在堆上而不是栈上之外,box 没有性能损失。

box适合用于如下场景:

  • 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候。(例如在一个list环境下,存放数据但是每个元素的大小在编译时又不确定)
  • 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候
  • 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候。

使用 Box<T> 在堆上储存数据

在讨论 Box&lt;T> 的堆存储用例之前,让我们熟悉一下语法以及如何与储存在 Box&lt;T> 中的值进行交互。使用box 在堆上储存一个i32

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}

这里定义了变量b存储在栈上,其值是一个指向被分配在堆上的值 5Boxb指向5所在的内存。

Box 创建递归类型

递归类型(recursive type)的值可以拥有另一个同类型的值作为其的一部分。这会产生一个问题因为 Rust 需要在编译时知道类型占用多少空间。递归类型的值嵌套理论上可以无限的进行下去,所以Rust不知道递归类型需要多少空间。因为 box 有一个已知的大小,所以通过在循环类型定义中插入 box,就可以创建递归类型了。

例如这里有一个包含列表 1,2,3 的 cons list 的伪代码表示,其每一个列表在一个括号中:

(1, (2, (3, Nil)))

cons list利用两个参数来构造一个新的列表。通过对一个包含值的列表和另一个值调用 cons,可以构建由递归列表组成的 cons list。它并不是一个 Rust中常见的类型。大部分在 Rust中需要列表的时候,Vec&lt;T>是一个更好的选择。定义包含一个 cons list的枚举:

use crate::List::{Cons, Nil};

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

第一个 Cons 储存了1 和另一个List值。这个 List 是另一个包含 2Cons 值和下一个List 值。接着又有另一个存放了 3Cons 值和最后一个值为 NilList,非递归成员代表了列表的结尾。

如果尝试编译得到错误:

$ cargo run
   Compiling pointer v0.1.0 (/rsut/pointer)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:3:1
  |
3 | enum List {
  | ^^^^^^^^^
4 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
4 |     Cons(i32, Box&lt;List>),
  |               ++++    +

For more information about this error, try `rustc --explain E0072`.
error: could not compile `pointer` due to previous error

这个错误表明这个类型 “有无限的大小”。其原因是 List 的一个成员被定义为是递归的:它直接存放了另一个相同类型的值。这意味着 Rust 无法计算为了存放 List 值到底需要多少空间。

使用 Box<T> 给递归类型一个已知的大小

因为 Box&lt;T>是一个指针,我们总是知道它需要多少空间:指针的大小并不会根据其指向的数据量而改变。这意味着可以将 Box 放入 Cons成员中而不是直接存放另一个 List 值。Box 会指向另一个位于堆上的 List 值,而不是存放在Cons成员中:

use crate::List::{Cons, Nil};

#[derive(Debug)]
enum List {
    Cons(i32, Box&lt;List>),
    Nil,
}

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    println!("{:#?}", list);
}

Cons 成员将会需要一个i32 的大小加上储存 box 指针数据的空间。Nil成员不储存值,所以它比 Cons 成员需要更少的空间。现在我们知道了任何 List 值最多需要一个 i32 加上 box 指针数据的大小。通过使用 box,打破了这无限递归的连锁,这样编译器就能够计算出储存 List 值需要的大小了。现在 Cons 成员看起来像什么: image.png box只提供了间接存储和堆分配;他们并没有任何其他特殊的功能。

Box&lt;T> 类型是一个智能指针,因为它实现了 Deref trait,它允许 Box&lt;T> 值被当作引用对待。当 Box&lt;T>值离开作用域时,由于 Box&lt;T> 类型 Drop trait 的实现,box所指向的堆数据也会被清除。

解引用 Deref trait

实现 Dereftrait 允许我们重载 解引用运算符(dereference operator)*。通过这种方式实现Deref trait 的智能指针可以被当作常规引用来对待。

追踪指针的值

常规引用是一个指针类型,一种理解指针的方式是将其看成指向储存在其他某处值的箭头。创建了一个i32值的引用,接着使用解引用运算符来跟踪所引用的值:

fn main() {
    let a = 5;
    let b = &a;

    assert_eq!(5, a);
    assert_eq!(5, *b);
}

变量 a 存放了一个 i325b 等于 a 的一个引用。可以断言 a 等于 5。然而,如果希望对 b的值做出断言,必须使用 *b来追踪引用所指向的值(也就是 解引用),这样编译器就可以比较实际的值了。一旦解引用了 b,就可以访问 b 所指向的整型值并可以与 5 做比较。

相反如果尝试编写assert_eq!(5, b);,则会得到如下编译错误:

$ cargo run 
   Compiling pointer v0.1.0 (/rsut/pointer)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, b);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq&lt;&{integer}>` is not implemented for `{integer}`
  = help: the following other types implement trait `PartialEq&lt;Rhs>`:
            f32
            f64
            i128
            i16
            i32
            i64
            i8
            isize
          and 6 others
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `pointer` due to previous error

不允许比较数字的引用与数字,因为它们是不同的类型。必须使用解引用运算符追踪引用所指向的值。

像引用一样使用 Box<T>

可以使用Box&lt;T> 代替引用来重写代码:

fn main() {
    let a = 5;
    let b = Box::new(a);

    assert_eq!(5, a);
    assert_eq!(5, *b);
}

两个示例主要不同的地方就是将 b 设置为一个指向 a 值拷贝的 Box&lt;T>实例,而不是指向a 值的引用。在最后的断言中,可以使用解引用运算符以 b 为引用时相同的方式追踪Box&lt;T> 的指针。

自定义智能指针

为了体会默认情况下智能指针与引用的不同,让我们创建一个类似于标准库提供的Box&lt;T>类型的智能指针:

struct MyBox&lt;T>(T);

impl&lt;T> MyBox&lt;T> {
    fn new(t: T) -> Self {
        MyBox(t)
    }
}

这里定义了一个结构体 MyBox 并声明了一个泛型参数T,因为我们希望其可以存放任何类型的值。MyBox 是一个包含T类型元素的元组结构体。MyBox::new函数获取一个 T 类型的参数并返回一个存放传入值的 MyBox 实例。

fn main() {
    let a = 5;
    let b = MyBox::new(a);

    assert_eq!(5, a);
    assert_eq!(5, *b);
}

尝试以使用引用和 Box&lt;T> 相同的方式使用 MyBox&lt;T>,得到的编译错误是:

$ cargo run
   Compiling pointer v0.1.0 (/rsut/pointer)
error[E0614]: type `MyBox&lt;{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *b);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `pointer` due to previous error

MyBox&lt;T> 类型不能解引用,因为我们尚未在该类型实现这个功能。为了启用*运算符的解引用功能,需要实现 Deref trait。

实现 Deref trait

为了实现 trait,需要提供 trait 所需的方法实现。Dereftrait,由标准库提供,要求实现名为deref 的方法,其借用 self 并返回一个内部数据的引用:

impl&lt;T> Deref for MyBox&lt;T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

type Target = T; 语法定义了用于此trait 的关联类型。关联类型是一个稍有不同的定义泛型参数的方式,现在不用管它;deref方法体中写入了 &self.0,这样 deref 返回了我希望通过 *运算符访问的值的引用。.0用来访问元组结构体的第一个元素。

当我们在输入 *b时,Rust 事实上在底层运行了如下代码:

*(b.deref())

Rust*运算符替换为先调用deref方法再进行普通解引用的操作,如此我们便不用担心是否还需手动调用deref方法了。

解引用多态

Deref 强制转换可以将 &String 转换为 &str,因为 String 实现了Deref trait 因此可以返回 &strDeref强制转换的加入使得Rust 程序员编写函数和方法调用时无需增加过多显式使用&* 的引用和解引用:

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

Rust 可以通过deref 调用将 &MyBox&lt;String> 变为 &String。标准库中提供了String 上的Deref实现,其会返回字符串 sliceRust 再次调用 deref&String 变为 &str,这就符合 hello函数的定义了。

如果 Rust没有实现 Deref 强制转换,为了使用 &MyBox&lt;String>类型的值调用 hello,则不得不编写自行解引用:

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

解引用多态有如下三种情况:

  • 当 T: Deref 时从 &T 到 &U。
  • 当 T: DerefMut 时从 &mut T 到 &mut U。
  • 当 T: Deref 时从 &mut T 到 &U。(注意:此处反之是不可能的)

Drop trait

对于智能指针模式来说第二个重要的 trait 是 Drop,其允许我们在值要离开作用域时执行一些代码。指定在值离开作用域时应该执行的代码的方式是实现 Drop trait。Drop trait 要求实现一个叫做drop的方法,它获取一个 self的可变引用。

struct Dog {
    name: String,
}

impl Drop for Dog {
    fn drop(&mut self) {
        println!("{} 资源被释放", self.name);
    }
}
fn main() {
    let a = Dog {
        name: "小白".to_string(),
    };
    {
        let b = Dog {
            name: "小黑".to_string(),
        };
    }
    let c = Dog {
        name: "小黄".to_string(),
    };
}

我们在 Dog 上实现了 Drop trait,并提供了一个调用 println!drop方法实现。drop 函数体是放置任何当类型实例离开作用域时期望运行的逻辑的地方。

当运行这个程序,会出现如下输出:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/pointer`
小黑 资源被释放
小黄 资源被释放
小白 资源被释放

当实例离开作用域 Rust 会自动调用 drop,并调用我们指定的代码。 b由于生命周期小于ac所以优先被释放,同生命周期变量以被创建时相反的顺序被释放,所以 ca 之前被释放。

drop 提前释放

整个 Drop trait 存在的意义在于其是自动处理的。然而,有时你可能需要提早清理某个值。一个例子是当使用智能指针管理锁时;你可能希望强制运行 drop 方法来释放锁以便作用域中的其他代码可以获取锁:

fn main() {
    let a = Dog {
        name: "小白".to_string(),
    };
    drop(a);
    {
        let b = Dog {
            name: "小黑".to_string(),
        };
    }
    let c = Dog {
        name: "小黄".to_string(),
    };
}

运行这段代码会打印出如下:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.18s
     Running `target/debug/pointer`
小白 资源被清理
小黑 资源被清理
小黄 资源被清理

Rc<T> 和 Arc<T>

Rust建立在所有权之上的这一套机制,它要求一个资源同一时刻有且只能有一个拥有所有权的绑定或 &mut引用,这在大部分的情况下保证了内存的安全。但是这样的设计是相当严格的,在另外一些情况下,它限制了程序的书写,无法实现某些功能。因此,Ruststd 库中提供了额外的措施来补充所有权机制,以应对更广泛的场景。

默认 Rust 中,对一个资源,同一时刻,有且只有一个所有权拥有者。RcArc使用引用计数的方法,让程序在同一时刻,实现同一资源的多个所有权拥有者,多个拥有者共享资源。

Rc

Rc 用于同一线程内部,通过 use std::rc::Rc 来引入。它有以下几个特点:

  1. Rc 包装起来的类型对象,是 immutable的,即 不可变的。即你无法修改 Rc&lt;T>中的 T 对象,只能读;
  2. 一旦最后一个拥有者消失,则资源会被自动回收,这个生命周期是在编译期就确定下来的;
  3. Rc 只能用于同一线程内部,不能用于线程之间的对象共享(不能跨线程传递);
  4. Rc 实际上是一个指针,它不影响包裹对象的方法调用形式(即不存在先解开包裹再调用值这一说)。

例子:

use std::rc::Rc;

fn main() {
    let f = Rc::new(5);

    let _f2 = f.clone();

    let _f3 = f.clone();

    println!("f 计数 = {}", Rc::strong_count(&f));
}

Rc Weak

Weak 通过 use std::rc::Weak 来引入。

Rc 是一个引用计数指针,而 Weak 是一个指针,但不增加引用计数,是RcWeak 版。它有以下几个特点:

  1. 可访问,但不拥有。不增加引用计数,因此,不会对资源回收管理造成影响;
  2. 可由 Rc&lt;T> 调用 downgrade 方法而转换成 Weak&lt;T>
  3. Weak&lt;T>可以使用 upgrade 方法转换成Option&lt;Rc&lt;T>>,如果资源已经被释放,则 Option 值为 None
  4. 常用于解决循环引用的问题。

例子:

use std::rc::Rc;

fn main() {
    let f = Rc::new(5);

    let w_f = Rc::downgrade(&f);

    drop(f);

    let s_f = w_f.upgrade();

    println!("{:?}", s_f);
}

如果在转换成 Option&lt;Rc&lt;T>>之前是否那Option 值为None

Arc

Arc 是原子引用计数,是Rc 的多线程版本。Arc 通过 std::sync::Arc引入。 它的特点:

  1. Arc可跨线程传递,用于跨线程共享一个对象;
  2. Arc 包裹起来的类型对象,对可变性没有要求;
  3. 一旦最后一个拥有者消失,则资源会被自动回收,这个生命周期是在编译期就确定下来的;
  4. Arc实际上是一个指针,它不影响包裹对象的方法调用形式(即不存在先解开包裹再调用值这一说);
  5. Arc 对于多线程的共享状态几乎是必须的(减少复制,提高性能)。

Arc Weak

Rc 类似,Arc 也有一个对应的 Weak类型,从 std::sync::Weak引入。

意义与用法与 Rc Weak基本一致,不同的点是这是多线程的版本。故不再赘述。

Arc将在多线程详细讨论,这里不过多讨论。

Cell<T> 和 RefCell<T>

前面我们提到,Rust 通过其所有权机制,严格控制拥有和借用关系,来保证程序的安全,并且这种安全是在编译期可计算、可预测的。但是这种严格的控制,有时也会带来灵活性的丧失,有的场景下甚至还满足不了需求。

因此,Rust标准库中,设计了这样一个系统的组件:Cell, RefCell,它们弥补了 Rust所有权机制在灵活性上和某些场景下的不足。同时,又没有打破 Rust 的核心设计。它们的出现,使得 Rust 革命性的语言理论设计更加完整,更加实用。

具体是因为,它们提供了 内部可变性(相对于标准的继承可变性来讲的)。

通常,我们要修改一个对象,必须

  • 成为它的拥有者,并且声明 mut
  • 或 以 &mut 的形式,借用;

而通过 Cell, RefCell,我们可以在需要的时候,就可以修改里面的对象。而不受编译期静态借用规则束缚。

Cell

Cell 有如下特点:

  1. Cell&lt;T> 只能用于 T 实现了 Copy 的情况;

.get()

.get()方法,返回内部值的一个拷贝。比如:

use std::cell::Cell;

fn main() {
    let c = Cell::new(5);

    let f = c.get();

    println!("f = {}", f);
}

.set()

.set() 方法,更新值:

use std::cell::Cell;

fn main() {
    let c = Cell::new(5);

    c.set(10);

    println!("c = {}", c.get());
}

RefCell

相对于 Cell 只能包裹实现了 Copy 的类型,RefCell用于更普遍的情况(其它情况都用RefCell)。

相对于标准情况的 静态借用,RefCell 实现了 运行时借用,这个借用是临时的。这意味着,编译器对 RefCell 中的内容,不会做静态借用检查,也意味着,出了什么问题,用户自己负责。

RefCell 的特点:

  1. 在不确定一个对象是否实现了 Copy 时,直接选RefCell
  2. 如果被包裹对象,同时被可变借用了两次,则会导致线程崩溃。所以需要用户自行判断;
  3. RefCell 只能用于线程内部,不能跨线程;
  4. RefCell 常常与Rc配合使用(都是单线程内部使用);

实例:

use std::{cell::RefCell, collections::HashMap, rc::Rc};

fn main() {
    let s_map: Rc&lt;RefCell&lt;_>> = Rc::new(RefCell::new(HashMap::new()));

    s_map.borrow_mut().insert("aaa", 111);
    s_map.borrow_mut().insert("bbb", 222);
    s_map.borrow_mut().insert("ccc", 333);
}

从上例可看出,用了 RefCell 后,外面是 不可变引用 的情况,一样地可以修改被包裹的对象。

.borrow()

不可变借用被包裹值。同时可存在多个不可变借用。

use std::cell::RefCell;

fn main() {
    let c = RefCell::new(5);

    let b_f1 = c.borrow();
    let b_f2 = c.borrow();
}

.borrow_mut()

可变借用被包裹值。同时只能有一个可变借用。

use std::cell::RefCell;

fn main() {
    let c = RefCell::new(5);

    let mut b_m_f = c.borrow_mut();
}

.into_inner()

取出包裹值

use std::cell::RefCell;

fn main() {
    let c = RefCell::new(5);
    println!("{}", c.into_inner());
}

循环引用综合示例

下面这个示例,表述的是如何实现两个对象的循环引用。综合演示了 Rc, Weak, RefCell 的用法

use std::{
    cell::RefCell,
    rc::{Rc, Weak},
};

struct Owner {
    name: String,
    gadgets: RefCell&lt;Vec&lt;Weak&lt;Gadget>>>,
}

struct Gadget {
    id: i32,
    owner: Rc&lt;Owner>,
}

fn main() {
    // 创建一个可计数的Owner。
    // 注意我们将gadgets赋给了Owner。
    // 也就是在这个结构体里, gadget_owner包含gadets
    let gadget_owner = Rc::new(Owner {
        name: "Gadget 1".to_string(),
        gadgets: RefCell::new(Vec::new()),
    });

    // 首先,我们创建两个gadget,他们分别持有 gadget_owner 的一个引用。
    let g1 = Rc::new(Gadget {
        id: 1,
        owner: gadget_owner.clone(),
    });

    let g2 = Rc::new(Gadget {
        id: 2,
        owner: gadget_owner.clone(),
    });

    // 我们将从gadget_owner的gadgets字段中持有其可变引用
    // 然后将两个gadget的Weak引用传给owner。
    gadget_owner.gadgets.borrow_mut().push(Rc::downgrade(&g1));
    gadget_owner.gadgets.borrow_mut().push(Rc::downgrade(&g2));

    // 遍历 gadget_owner的gadgets字段
    for gadget_opt in gadget_owner.gadgets.borrow().iter() {
        // gadget_opt 是一个 Weak&lt;Gadget> 。 因为 weak 指针不能保证他所引用的对象
        // 仍然存在。所以我们需要显式的调用 upgrade() 来通过其返回值(Option&lt;_>)来判
        // 断其所指向的对象是否存在。
        // 当然,这个Option为None的时候这个引用原对象就不存在了。
        if let Some(opt) = gadget_opt.upgrade() {
            println!("Gadget {} owned by {}", opt.id, opt.owner.name);
        }
    }

    // 在main函数的最后, gadget_owner, gadget1和daget2都被销毁。
    // 具体是,因为这几个结构体之间没有了强引用(`Rc&lt;T>`),所以,当他们销毁的时候。
    // 首先 gadget1和gadget2被销毁。
    // 然后因为gadget_owner的引用数量为0,所以这个对象可以被销毁了。
    // 循环引用问题也就避免了
}
  • 原创
  • 学分: 5
  • 分类: Rust
  • 标签:
点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。
127 订阅 31 篇文章

0 条评论

请先 登录 后评论
木头
木头
0xC020...10cf
江湖只有他的大名,没有他的介绍。