Rust中的智能指针

 Rust  指针 󰈭 6879字

通常说来, 指针是一个包含了内存地址的变量, 而内存地址引用/指向了另外的数据.

在Rust中, 最常见的指针类型是引用, 其借用其他的变量的值, 除了指向某个值以外就没有其他的功能了. 没有性能损耗, 是Rust中使用最多的指针.

Rust中的智能指针则相比于一般指针更加复杂, 其中包含诸如长度、容量、元信息等额外信息. 相比于引用会借用数据, 智能指针还能拥有其指向的数据, 然后再向他人提供服务. StringVec都是常见的智能指针.

智能指针往往基于结构体实现, 此外其最大的特点在于实现了DerefDrop特性: Deref使得智能指针可以像引用一样工作, Drop则允许智能指针自动进行数据收尾等工作.

两个相关的特征

何为智能指针?能不让你写出 ****s 形式的解引用,我认为就是智能: ),智能指针的名称来源,主要就在于它实现了 Deref 和 Drop 特征,这两个特征可以智能地帮助我们节省使用上的负担:

在正式看到智能指针前, 先研究一下与之相关的两个trait.

Deref解引用

常规的引用是一个指针类型, 其中包含了目标数据存储的内存地址, 对常规引用解引用就会获取对应地址上的数据值. 不过在Rust中如果对一个结构体类型的智能指针解引用, 编译器显然不知道该怎么办, 为此我们需要为其实现Deref特征.

 1struct MyBox<T>(T);		 // 定义一个元组结构体
 2
 3impl<T> MyBox<T> {
 4    fn new(x: T) -> MyBox<T> {
 5        MyBox(x)
 6    }
 7}
 8
 9use std::ops::Deref;
10impl<T> Deref for MyBox<T> {	// 实现Deref特征
11    type Target = T;
12
13    fn deref(&self) -> &Self::Target {
14        &self.0
15    }
16}
17
18fn main() {
19    let y = MyBox::new(5);
20
21    assert_eq!(5, *y);
22}

*的背后

对智能指针使用*解引用本质上是使用了*(y.deref())

至于 Rust 为何要使用这个有点啰嗦的方式实现,原因在于所有权系统的存在。如果 deref 方法直接返回一个值,而不是引用,那么该值的所有权将被转移给调用者,而我们不希望调用者仅仅只是 *T 一下,就拿走了智能指针中包含的值。

还有什么别的简洁的实现? 我倒是觉得这样子的实现还是蛮直观的.. 也并没有搞懂为什么这样就能符合所有权机制了…

隐式deref转换

若一个类型实现了 Deref 特征, 那它的引用在传给函数或方法时, 会根据参数签名来决定是否进行隐式的 Deref 转换; 如果该类型实现了Deref特征, 那么会自动在需要时进行类型转换, 赋值和实参传递必须使用&引用的方式来触发自动deref:

1fn main() {
2    let s = String::from("hello world");
3    display(&s)
4}
5
6fn display(s: &str) {
7    println!("{}",s);
8}

此外, Rust编译器支持连续的隐式deref转换, 直到找到合适的形式为止; 这带来了代码编写的简洁性, 不过带来的是可读性的降低, 一切都是权衡:)

不仅仅是函数中可以出现解引用, 方法中也可以出现解引用, MyBox是一个实现了Deref特征的结构体:

1fn main() {
2    let s = MyBox::new(String::from("hello, world"));
3    let s1: &str = &s;
4    let s2: String = s.to_string();
5}

MyBox没有实现to_string方法但能成功调用, 这是编译器自动进行隐式解引用的功劳. 方法调用会自动解引用, 不需要手动加入&来允许隐式解引用.

引用归一化

Rust编译器实际上只能对&v的形式进行解引用, 因此对于其他的类型都需要转化为该形状才能进行解引用:

  • 对于智能指针而言, 如上所述其, 会自动脱壳为内部的引用类型, 即变成内部的&v.

  • 对于多重&, 例如&&&&&&&&&&v, 其会自动归一成&v.

    • 标准库源码中为&T实现了Defef特征, 具有这样的默认方法:
    1impl<T: ?Sized> Deref for &T {
    2    type Target = T;
    3
    4    fn deref(&self) -> &T {
    5	*self
    6    }
    7}

    据此可知, &&&T会被解引用为&&T, 进而解引用为&T, 以此类推. 由此可以出现以下这种看上去很毒瘤的代码, 但是输出的结果均一致:

     1struct Foo;
     2impl Foo {
     3    fn foo(&self) { println!("Foo"); }
     4}
     5
     6fn main() {
     7    let f = &&Foo;
     8    f.foo();
     9    (&f).foo();
    10    (&&f).foo();
    11    (&&&&&&&&f).foo();
    12}
impl< T: ?Sized>的特征约束?

?Sized特征约束既可以接受Sized的类型也可以接受动态大小类型. 既然如此, 为什么还需要手动指明该特征约束呢? 直接删去不行吗? 事实上, Rust编译器会自动帮未显式指定的泛型类型加上Sized, 因此为了使用DST必须指定?Sized特征. 来源: Sized 和不定长类型 DST - Rust语言圣经(Rust Course)

三种Deref转换

事实上Rust还支持对可变引用的deref转换:

  • T: Deref<Target=U>, 可以将&T或者&mut T转换为&U

    • 将一个可变引用变成一个不可变引用, 并不会破坏所有权规则, 但是反之则有可能破坏, 因而不被允许.
  • T: DerefMut<Target=U>, 可以将&mut T转换成&mut U

Drop释放资源

在 Rust 中, 可以通过实现Drop特征, 指定在一个变量超出作用域时执行一段特定的代码, 最终编译器将帮你自动插入这段收尾代码. 这样, 就无需在每一个使用该变量的地方, 都手动写一段代码来进行收尾工作和资源释放.

看到这样一个简单的例子:

 1struct HasDrop1;
 2struct HasDrop2;
 3impl Drop for HasDrop1 {
 4    fn drop(&mut self) {
 5        println!("Dropping HasDrop1!");
 6    }
 7}
 8impl Drop for HasDrop2 {
 9    fn drop(&mut self) {
10        println!("Dropping HasDrop2!");
11    }
12}
13struct HasTwoDrops {
14    one: HasDrop1,
15    two: HasDrop2,
16}
17impl Drop for HasTwoDrops {
18    fn drop(&mut self) {
19        println!("Dropping HasTwoDrops!");
20    }
21}
22
23struct Foo;
24
25impl Drop for Foo {
26    fn drop(&mut self) {
27        println!("Dropping Foo!")
28    }
29}
30
31fn main() {
32    let _x = HasTwoDrops {
33        two: HasDrop2,
34        one: HasDrop1,
35    };
36    let _foo = Foo;
37    println!("Running!");
38}
39
40// 输出:
41// Running!
42// Dropping Foo!
43// Dropping HasTwoDrops!
44// Dropping HasDrop1!
45// Dropping HasDrop2!

从中可以看出有关Drop的实现方式以及有关调用顺序的规律:

  • 在变量的级别, 按照逆序的方式调用Drop;

  • 在结构体内部, 按照顺序的方式调用Drop

手动Drop回收

在一些场景下我们可能希望提前手动调用Drop(如使用智能指针管理锁时), 可能会写出这样的代码:

 1#[derive(Debug)]
 2struct Foo;
 3
 4impl Drop for Foo {
 5    fn drop(&mut self) {
 6        println!("Dropping Foo!")
 7    }
 8}
 9
10fn main() {
11    let foo = Foo;
12    foo.drop();
13    println!("Running!:{:?}", foo);	// 所有权未转移, 因而看上去后续仍能使用
14}

不过这并不行, Rust编译器不允许显示调用析构函数. 其要求我们使用std::mem::drop函数, 其签名为:

1pub fn drop<T>(_x: T)

使用drop函数而不是Drop特征下的方法可以拿走变量的所有权, 而特征方法只是借用了目标的可变引用. 这样可以保证后续再对该变量的使用必定会导致编译错误, 十分的安全.

Copy与Drop互斥

当实现了Drop特征后, 就无法实现Copy特征. 因为Copy特征会被编译器隐式的复制, 因此非常难以预测析构函数执行的时间和频率, 因而规定这两个特征互斥.

特征Copy和Clone?

Copy 的全名是 std::marker::Copy. std::marker模块里的所有 trait 都是特殊的 trait. 目前稳定的有四个, 它们是 Copy、Send、Sized、Sync. 它们的特殊之处在于它们是跟编译器密切绑定的, impl 这些 trait 对编译器的行为有重要影响. 在编译器眼里, 它们与其它的 trait 不一样. 这几个 trait 内部都没有方法, 它们的唯一任务是, 给类型打一个标记, 表明它符合某种约定, 这些约定会影响编译器的静态检查以及代码生成.
如果一个类型 impl 了 Copy trait, 意味着任何时候, 我们可以通过简单的内存拷贝(C语言的memcpy)实现该类型的复制, 而不会产生任何问题. 一旦一个类型实现了 Copy trait, 那么它在变量绑定、函数参数传递、函数返回值传递等场景下, 它都是 copy 语义, 而不再是默认的 move 语义. Rust规定, 对于自定义类型, 只有所有的成员都实现了 Copy trait, 这个类型才有资格实现 Copy trait.
Clone 的全名是 std::clone::Clone, 其有两个关联方法:

  • clone(&self) -> Self;
  • fn clone_from(&mut self, source: &Self) 其中 clone_from 是有默认实现的, 它依赖于 clone 方法的实现; clone 方法没有默认实现, 需要我们手动实现. clone 方法一般用于基于语义的复制操作. 所以, 它做什么事情, 跟具体类型的作用息息相关. 比如对于 Box 类型, clone 就是执行的深拷贝, 而对于 Rc 类型, clone 做的事情就是把引用计数值加1.
    参考来源: Clone VS Copy - 知乎

Box<T>堆对象分配

有关程序语言中的栈和堆的概念就不赘述了, 这里只介绍一些Rust中有关堆栈的事实:

  • Rust中main线程的栈大小为8MB, 而普通线程为2MB

  • Rust堆上的对象都拥有一个所有者, 受到所有权规则的限制. 当发生赋值时, 发生的是所有权的转移(本质是栈上的引用/智能指针的拷贝)

堆栈的性能问题?

栈的性能未必比堆高:

  • 小型数据, 在栈上的分配性能和读取性能都要比堆上高
  • 中型数据, 栈上分配性能高, 但是读取性能和堆上并无区别, 因为无法利用寄存器或 CPU 高速缓存, 最终还是要经过一次内存寻址
  • 大型数据, 只建议在堆上分配和使用

Box只是简单的封装, 除了将值存放在堆上, 就没有什么其他性能上的损耗了. 其功能比较单一, 适用于下面的一些实际场景.

  • 避免栈上数据的拷贝. 当栈上数据转移所有权时, 实际上是把数据拷贝了一份, 最终新旧变量各自拥有不同的数据, 数据的所有权未发生变化; 而堆上则不然, 底层数据并不会被拷贝, 转移所有权仅仅是复制一份栈中的指针, 再将新的指针赋予新的变量, 然后让拥有旧指针的变量失效, 最终完成了所有权的转移.

  • 将DST(动态大小类型)变为Sized类型(固定大小类型). 比如递归类型是一种编译期间无法得知大小的类型, 其理论上可以嵌套下去:

    1enum List {
    2    Cons(i32, List),
    3    Nil,
    4}

    由于无限嵌套的问题, Rust编译器认为这是一个DST类型并报错; 解决方案是使用Box指针使其变成一个固定大小的类型!

    1enum List {
    2    Cons(i32, Box<List>),
    3    Nil,
    4}
  • 特征对象可以用来实现不同类型组成的数组, 事实上特征也是一种DST类型, 而特征对象做的事情就是将DST变为固定大小类型. 回忆: Rust-泛型与特征 - rqdmap | blog

Box的leak关联函数

Box提供了一个有用的关联函数:Box::leak, 它可以消费掉 Box 并且强制目标值从内存中泄漏, 然后将其变为’static生命周期, 最终该变量将和程序活得一样久.

这有什么作用呢? 比如:

 1#[derive(Debug)]
 2struct Config {
 3    a: String,
 4    b: String,
 5}
 6static mut CONFIG: Option<&mut Config> = None;
 7
 8fn main() {
 9    unsafe {
10        CONFIG = Some(&mut Config {
11            a: "A".to_string(),
12            b: "B".to_string(),
13        });
14
15        println!("{:?}", CONFIG)
16    }
17}

在这里我们初始化了一个静态变量CONFIG, 初始值为None, 希望在main函数运行时进行初始化, 这可以做到吗?

 1error[E0716]: temporary value dropped while borrowed
 2  --> src/main.rs:10:28
 3   |
 410 |            CONFIG = Some(&mut Config {
 5   |  __________-__________________^
 6   | | _________|
 7   | ||
 811 | ||             a: "A".to_string(),
 912 | ||             b: "B".to_string(),
1013 | ||         });
11   | ||         ^-- temporary value is freed at the end of this statement
12   | ||_________||
13   | |__________|assignment requires that borrow lasts for `'static`
14   |            creates a temporary value which is freed while still in use

rust编译器告诉我们这不行, 因为试图将两个生命周期不如CONFIG('static)的量绑定在CONFIG上, 此时就可以使用Box leak来帮忙:

 1#[derive(Debug)]
 2struct Config {
 3    a: String,
 4    b: String
 5}
 6static mut CONFIG: Option<&mut Config> = None;
 7
 8fn main() {
 9    let c = Box::new(Config {
10        a: "A".to_string(),
11        b: "B".to_string(),
12    });
13
14    unsafe {
15        // 将`c`从内存中泄漏,变成`'static`生命周期
16        CONFIG = Some(Box::leak(c));
17        println!("{:?}", CONFIG);
18    }
19}

通过这种"主动内存泄漏"的方式, Box内的变量变成了全局生命周期! 这样就可以用它来初始化CONFIG变量了.

Rc与Arc引用计数

Rust所有权机制要求一个值只能有一个所有者, 但是在一些情况下(图结构中一个节点有多条边相连, 多线程中多个线程拥有同一个数据)事情会变得非常棘手. 为了解决此类问题, Rust在所有权机制之外引用了引用计数的方式来允许一个数据在同一时刻拥有多个所有者.

RcArc便是这样的实现技术, 前者用于单线程而后者用于多线程.

当我们希望在堆上分配一个对象供程序的多个部分使用且无法确定哪个部分最后一个结束时, 就可以使用 Rc 成为数据值的所有者, 这是一个示例:

1use std::rc::Rc;
2fn main() {
3    let a = Rc::new(String::from("hello, world"));
4    let b = Rc::clone(&a);
5
6    assert_eq!(2, Rc::strong_count(&a));
7    assert_eq!(Rc::strong_count(&a), Rc::strong_count(&b))
8}
  • 首先使用Rc::new创建新的一个智能指针, 该指针指向底层的字符串数据; 指针创建时引用计数将加1.

  • 随后使用Rc::clone克隆一份智能指针, 并赋给b; 由于ab是同一个指针的两个副本, 因而他们的引用计数结果此时均为2.

    • 这里的clone与特征Clone有所不同, 其不是通常的深拷贝, 而仅仅是复制了智能指针并加一引用计数, 并没有克隆底层数据, 因而效率是非常高的.

当Rc内的值的最后一个拥有者消失, 则资源自动被回收. 此外, 由于Rc是一个智能指针, 其也实现了Deref特征, 因而可以无需解开Rc就使用里面的T.

Rc是不可变引用

需要注意, Rc<T>是指向底层数据的不可变的引用, 因此你无法通过它来修改数据, 这也符合 Rust 的借用规则: 要么存在多个不可变借用, 要么只能存在一个可变借用.
但是实际开发中我们往往需要对数据进行修改, 这时单独使用 Rc 无法满足我们的需求, 需要配合其它数据类型来一起使用, 例如内部可变性的 RefCell 类型以及互斥锁 Mutex. 事实上, 在多线程编程中, Arc 跟 Mutex 锁的组合使用非常常见, 它们既可以让我们在不同的线程中共享数据, 又允许在各个线程中对其进行修改.

Cell与RefCell内部可变性

Rust严格的编译器有时候会导致丧失灵活性, 因而Rust提供了CellRefCell用于内部可变性, 允许在拥有不可变引用的同时修改目标数据, 这在其他的代码实现中是不可能做到的.

内部可变性的实现?

内部可变性的实现是因为 Rust 使用了 unsafe 来做到这一点, 但是对于使用者来说, 这些都是透明的, 因为这些不安全代码都被封装到了安全的 API 中

Cell和RefCell在功能上是一致的, 区别在于Cell仅适用于T实现了Copy特征的情况. 比如:

1use std::cell::Cell;
2fn main() {
3  let c = Cell::new("asdf");
4  let one = c.get();
5  c.set("qwer");
6  let two = c.get();
7  println!("{},{}", one, two);
8}
9// asdf,qwer

利用了Cell, 可以在取Cell内的值到one中后, 再修改Cell内的值, 这是违背了Rust借用规则的.

在实际开发中, Cell使用的并不多, 因为我们要解决的往往是可变、不可变引用共存导致的问题, 此时就需要借助于 RefCell 来达成目的. 比如:

1use std::cell::RefCell;
2
3fn main() {
4    let s = RefCell::new(String::from("hello, world"));
5    let s1 = s.borrow();
6    let s2 = s.borrow_mut();
7
8    println!("{},{}", s1, s2);
9}

这段代码中同时存在可变引用和不可变引用, 不过由于封装在RefCell中, Rust编译器会通过代码的编译! 那么代价是什么呢?

Rust规则 智能指针带来的额外规则
一个数据只有一个所有者 Rc/Arc让一个数据可以拥有多个所有者
要么多个不可变借用,要么一个可变借用 RefCell实现编译期可变、不可变引用共存
违背规则导致编译错误 违背规则导致运行时panic

借用规则的违反从编译器错误变成运行时错误, 这很难说让人满意!

不过RefCell也是有用武之处的, 由于Rust编译器宁可错杀绝不放过, 因而可能会导致误报. RefCell正是用于当程序员确信代码正确, 编译器误判时. 在一些大型程序中, 很难管理清楚可变和不可变引用, 此时使用RefCell可以让编译器不用管这么多闲事, 一旦有人做了错误的使用, 程序会直接pacnic并报错, 利用报错信息进行再调试也是可行的.

RefCell是否能绕过借用规则?

RefCell 只是将借用规则从编译期推迟到程序运行期, 并不能帮你绕过这个规则, 违背借用规则会导致运行期的 panic

相比于Cell的零开销, RefCell有一些运行时开销, 因为其包含一个字大小的指示器说明借用状态, 进而产生一些开销.

内部可变性

对一个不可变的值进行可变引用, 就是内部可变性. 比如:

 1// 定义在外部库中的特征
 2pub trait Messenger {
 3    fn send(&self, msg: String);
 4}
 5
 6// --------------------------
 7// 我们的代码中的数据结构和实现
 8struct MsgQueue {
 9    msg_cache: Vec<String>,
10}
11
12impl Messenger for MsgQueue {
13    fn send(&self, msg: String) {
14        self.msg_cache.push(msg)
15    }
16}

外部库实现了一个消息发送的特征, 由于发送消息并不需要修改自身, 因而签名中使用&self不可变引用是合理的; 但是在我们的实现中希望实现一个缓冲区以提高性能, 需要先将消息插入到本地缓存中, 这就出现了问题: 由于外部库给出的签名是不可变引用, 因而在该特征方法send中无法通过常规的方法直接修改该结构体内部的成员.

此时则可以仰仗RefCell:

 1use std::cell::RefCell;
 2pub trait Messenger {
 3    fn send(&self, msg: String);
 4}
 5
 6pub struct MsgQueue {
 7    msg_cache: RefCell<Vec<String>>,
 8}
 9
10impl Messenger for MsgQueue {
11    fn send(&self, msg: String) {
12        self.msg_cache.borrow_mut().push(msg)
13    }
14}
15
16fn main() {
17    let mq = MsgQueue {
18        msg_cache: RefCell::new(Vec::new()),
19    };
20    mq.send("hello, world".to_string());
21}

通过包裹一层 RefCell, 即可成功的让 &self 中的 msg_cache 成为一个可变值, 然后实现对其的修改.

Rc+Refcell组合拳

常见的手段是Rc+RefCell的组合使用. 比如:

 1use std::cell::RefCell;
 2use std::rc::Rc;
 3fn main() {
 4    let s = Rc::new(RefCell::new("我很善变,还拥有多个主人".to_string()));
 5
 6    let s1 = s.clone();
 7    let s2 = s.clone();
 8    s2.borrow_mut().push_str(", on yeah!");
 9
10    println!("{:?}\n{:?}\n{:?}", s, s1, s2);
11}
12
13// RefCell { value: "我很善变,还拥有多个主人, on yeah!" }
14// RefCell { value: "我很善变,还拥有多个主人, on yeah!" }
15// RefCell { value: "我很善变,还拥有多个主人, on yeah!" }
  • RefCell包裹一个String类型, 允许编译期间其同时被可变引用和不可变引用; 不过在上述的代码中并没有绑定任何类型的引用到某个变量上, 而是直接匿名地使用了其可变引用并添加了一些字符(L8)!

  • Rc进一步包含RefCell, 这使得s, s1和s2三个变量的表现一致; 当s2插入了一些字符, 那么s和s1也会同步变化.

对于这样智能指针的组合, 性能表现如何呢?

  • 性能? 高, 大致相当于没有线程安全版本的 C++std::shared_ptr指针

  • 内存消耗? 低, 两者组合的结构与下述的结构类似, 仅仅多分配了几个usize/isize

     1struct Wrapper<T> {
     2    // Rc
     3    strong_count: usize,
     4    weak_count: usize,
     5
     6    // Refcell
     7    borrow_count: isize,
     8
     9    // 包裹的数据
    10    item: T,
    11}
  • CPU消耗? 低

    • 对Rc解引用是免费的(编译期), 不过*间接取值并不免费

    • Rc的克隆需要比较一次引用计数值与usize:Max, 并做加1操作; 释放需要减1, 再比较一次引用计数值与0的关系

    • RefCell的不可变借用与释放将isize的计数值加1或减1, 并与0比较; 可变借用类似.

  • Cache Miss? 无法具体说明是否与CPU缓存亲和, 只能在实际场景测试.

总的来说, 两者的组合不会带来巨大的内存和CPU消耗, 但是一次额外的间接取值和CPU缓存可能会在某些场景存在一些问题.

嗨! 这里是 rqdmap 的个人博客, 我正关注 GNU/Linux 桌面系统, Linux 内核, 后端开发, Python, Rust 以及一切有趣的计算机技术! 希望我的内容能对你有所帮助~
如果你遇到了任何问题, 包括但不限于: 博客内容说明不清楚或错误; 样式版面混乱等问题, 请通过邮箱 rqdmap@gmail.com 联系我!
修改记录:
  • 2023-08-28 22:49:22添加Box::leak相关内容
  • 2023-08-06 20:41:26Rust智能指针
Rust中的智能指针