学习笔记 20240805 Rust语言-Rust语言圣经-Box<T>

20240805

智能指针概述

在各个编程语言中,指针的概念几乎都是相同的:指针是一个包含了内存地址的变量,该内存地址引用或者指向了另外的数据。

在 Rust 中,最常见的指针类型是引用,引用通过 & 符号表示。不同于其它语言,引用在 Rust 中被赋予了更深层次的含义,那就是:借用其它变量的值。引用本身很简单,除了指向某个值外并没有其它的功能,也不会造成性能上的额外损耗,因此是 Rust 中使用最多的指针类型。

而智能指针则不然,它虽然也号称指针,但是它是一个复杂的家伙:通过比引用更复杂的数据结构,包含比引用更多的信息,例如元数据,当前长度,最大可用长度等。总之,Rust 的智能指针并不是独创,在 C++ 或者其他语言中也存在相似的概念。

Rust 标准库中定义的那些智能指针,虽重但强,可以提供比引用更多的功能特性,例如本章将讨论的引用计数智能指针。该智能指针允许你同时拥有同一个数据的多个所有权,它会跟踪每一个所有者并进行计数,当所有的所有者都归还后,该智能指针及指向的数据将自动被清理释放。

引用和智能指针的另一个不同在于前者仅仅是借用了数据,而后者往往可以拥有它们指向的数据,然后再为其它人提供服务。

在之前的章节中,实际上我们已经见识过多种智能指针,例如动态字符串 String 和动态数组 Vec,它们的数据结构中不仅仅包含了指向底层数据的指针,还包含了当前长度、最大长度等信息,其中 String 智能指针还提供了一种担保信息:所有的数据都是合法的 UTF-8 格式。

智能指针往往是基于结构体实现,它与我们自定义的结构体最大的区别在于它实现了 Deref 和 Drop 特征:

  • Deref 可以让智能指针像引用那样工作,这样你就可以写出同时支持智能指针和引用的代码,例如 *T
  • Drop 允许你指定智能指针超出作用域后自动执行的代码,例如做一些数据清除等收尾工作

Box<T> 堆对象分配

Box<T> 允许你将一个值分配到堆上,然后在栈上保留一个智能指针指向堆上的数据。这里补充一些堆栈的知识

Rust 中的堆栈

高级语言 Python/Java 等往往会弱化堆栈的概念,但是要用好 C/C++/Rust,就必须对堆栈有深入的了解,原因是两者的内存管理方式不同:前者有 GC 垃圾回收机制,因此无需你去关心内存的细节。

栈内存从高位地址向下增长,且栈内存是连续分配的,一般来说操作系统对栈内存的大小都有限制,因此 C 语言中无法创建任意长度的数组。在 Rust 中,main 线程的栈大小是 8MB,普通线程是 2MB,在函数调用时会在其中创建一个临时栈空间,调用结束后 Rust 会让这个栈空间里的对象自动进入 Drop 流程,最后栈顶指针自动移动到上一个调用栈顶,无需程序员手动干预,因而栈内存申请和释放是非常高效的。

与栈相反,堆上内存则是从低位地址向上增长,堆内存通常只受物理内存限制,而且通常是不连续的,因此从性能的角度看,栈往往比堆更高。

相比其它语言,Rust 堆上对象还有一个特殊之处,它们都拥有一个所有者,因此受所有权规则的限制:当赋值时,发生的是所有权的转移(只需浅拷贝栈上的引用或智能指针即可),例如以下代码:

fn main() {
    let b = foo("world");
    println!("{}", b);
}

fn foo(x: &str) -> String {
    let a = "Hello, ".to_string() + x;
    a
}

在 foo 函数中,a 是 String 类型,它其实是一个智能指针结构体,该智能指针存储在函数栈中,指向堆上的字符串数据。当被从 foo 函数转移给 main 中的 b 变量时,栈上的智能指针被复制一份赋予给 b,而底层数据无需发生改变,这样就完成了所有权从 foo 函数内部到 b 的转移。

堆栈的性能

很多人可能会觉得栈的性能肯定比堆高,其实未必。 由于我们在后面的 性能专题( 这里原文作者并没有更新 ,自己找一些资料吧) 会专门讲解堆栈的性能问题,因此这里就大概给出结论:

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

总之,栈的分配速度肯定比堆上快,但是读取速度往往取决于你的数据能不能放入寄存器或 CPU 高速缓存。 因此不要仅仅因为堆上性能不如栈这个印象,就总是优先选择栈,导致代码更复杂的实现。

Box的使用场景

由于 Box 是简单的封装,除了将值存储在堆上外,并没有其它性能上的损耗。而性能和功能往往是鱼和熊掌,因此 Box 相比其它智能指针,功能较为单一,可以在以下场景中使用它:

  • 特意的将数据分配在堆上
  • 数据较大时,又不想在转移所有权时进行数据拷贝
  • 类型的大小在编译期无法确定,但是我们又需要固定大小的类型时
  • 特征对象,用于说明对象实现了一个特征,而不是某个特定的类型
使用 Box<T> 将数据存储在堆上

如果一个变量拥有一个数值 let a = 3,那变量 a 必然是存储在栈上的(整数类型属于简单类型,存储在栈上),那如果我们想要 a 的值存储在堆上就需要使用 Box<T>:

fn main() {
  let a: Box<i32> = Box::new(3);
  println!("a = {}", a); // a = 3

  // 下面一行代码将报错
  // let b = a + 1; // cannot add `{integer}` to `Box<{integer}>`
}

这样就可以创建一个智能指针指向了存储在堆上的 3,并且 a 持有了该指针。在本章的引言中,我们提到了智能指针往往都实现了 Deref 和 Drop 特征,因此:

  • println! 可以正常打印出 a 的值,是因为它隐式地调用了 Deref 对智能指针 a 进行了解引用
  • 最后一行代码 let b = a + 1 报错,是因为在表达式中,我们无法自动隐式地执行 Deref 解引用操作,你需要使用 * 操作符 let b = *a + 1,来显式的进行解引用
  • a 持有的智能指针将在作用域结束(main 函数结束)时,被释放掉,这是因为 Box<T> 实现了 Drop 特征

以上的例子在实际代码中其实很少会存在,因为将一个简单的值分配到堆上并没有太大的意义。将其分配在栈上,由于寄存器、CPU 缓存的原因,它的性能将更好,而且代码可读性也更好。

避免栈上数据的拷贝

当栈上数据转移所有权时,实际上是把数据拷贝了一份,最终新旧变量各自拥有不同的数据,因此所有权并未转移。

而堆上则不然,底层数据并不会被拷贝,转移所有权仅仅是复制一份栈中的指针,再将新的指针赋予新的变量,然后让拥有旧指针的变量失效,最终完成了所有权的转移:

use std::ptr::addr_of;

fn main() {
  // 在栈上创建一个长度为1000的数组
  let arr = [0;1000];
  // 将arr所有权转移arr1,由于 `arr` 分配在栈上,因此这里实际上是直接重新深拷贝了一份数据
  let arr1 = arr;
  
  //使用std::ptr::addr_of的addr_of来查看两个数组在内存中的起始位置
  //结果是是两个不同的位置,说明当栈上数据转移所有权时,实际上是把数据拷贝了一份,最终新旧变量各自拥有不同的数据
  let address = addr_of!(arr);
  let address_1 = addr_of!(arr1);
  
  //
  println!("The memory address of arr is: {:?}", address);
  println!("The memory address of arr1 is: {:?}", address_1);

  // arr 和 arr1 都拥有各自的栈上数组,因此不会报错
  println!("arr 的长度是 {:?}", arr.len());
  println!("arr1 的长度是 {:?}", arr1.len());

  // 在堆上创建一个长度为1000的数组,然后使用一个智能指针指向它
  let arr = Box::new([0;1000]);
  // 将堆上数组的所有权转移给 arr1,由于数据在堆上,因此仅仅拷贝了智能指针的结构体,底层数据并没有被拷贝
  // 所有权顺利转移给 arr1,arr 不再拥有所有权
  let arr1 = arr;
  println!("arr1 的长度是 {:?}", arr1.len());
  // 由于 arr 不再拥有底层数组的所有权,因此下面代码将报错
  // println!("{:?}", arr.len());
}

运行结果如下:

The memory address of arr is: 0x7ffcdf540680
The memory address of arr1 is: 0x7ffcdf541620
arr 的长度是 1000
arr1 的长度是 1000
arr1 的长度是 1000

从以上代码,可以清晰看出,如果大块的数据存储在栈中,并且是可以深拷贝的数据,那么当发生所有权转移时,会发生大量数据在栈中的复制,占用大量空间。而放入堆中只需复制指针并且赋予给新的变量即可。此时 Box 就成为了我们最好的帮手。

PS:为了检测Box是否是在堆上分配数据,将let arr = Box::new([0;1000]);改为let arr = Box::new([0;2097153]); 因为一个i32类型大小是4B,当数组元素达到2097152(2*1024*1024 = 2M)时,这个数组刚好是大小8MB,是main函数栈的最大容量,而我们只要选用比这个数大即可,所以这里选用2097153即可。

fn main() {
  // 在堆上创建一个长度为1000的数组,然后使用一个智能指针指向它
  let arr = Box::new([0;2097153]);
  // 将堆上数组的所有权转移给 arr1,由于数据在堆上,因此仅仅拷贝了智能指针的结构体,底层数据并没有被拷贝
  // 所有权顺利转移给 arr1,arr 不再拥有所有权
  let arr1 = arr;
  println!("{:?}", arr1.len());
}

报错如下:

thread 'main' has overflowed its stack
fatal runtime error: stack overflow

这就奇怪了,我们明明是用的Box::new,理论上应该是分配到堆上,为什么会报错栈溢出呢?

我们运行时默认使用的是Debug模式,debug模式下会先在栈上分配,再复制到堆上,换成release 模式(用于生产环境,可以通过cargo build --release或cargo run --release来指定,不包含调试信息,专注于性能优化。,优化级别高,运行速度快,但编译速度慢)直接在堆上分配.

cargo run --release
2097153
将动态大小类型变为 Sized 固定大小类型

Rust 需要在编译时知道类型占用多少空间,如果一种类型在编译时无法知道具体的大小,那么被称为动态大小类型 DST。

其中一种无法在编译时知道大小的类型是递归类型:在类型定义中又使用到了自身,或者说该类型的值的一部分可以是相同类型的其它值,这种值的嵌套理论上可以无限进行下去,所以 Rust 不知道递归类型需要多少空间:

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

以上就是函数式语言中常见的 Cons List,它的每个节点包含一个 i32 值,还包含了一个新的 List,因此这种嵌套可以无限进行下去,Rust 认为该类型是一个 DST 类型,并给予报错:

error[E0072]: recursive type `List` has infinite size //递归类型 `List` 拥有无限长的大小
 --> src/main.rs:3:1
  |
3 | enum List {
  | ^^^^^^^^^ recursive type has infinite size
4 |     Cons(i32, List),
  |               ---- recursive without indirection

此时若想解决这个问题,就可以使用我们的 Box<T>:

enum List {
    Cons(i32, Box<List>),
    Nil,
}

只需要将 List 存储到堆上,然后使用一个智能指针指向它,即可完成从 DST 到 Sized 类型(固定大小类型)的华丽转变。

特征对象

在 Rust 中,想实现不同类型组成的数组只有两个办法:枚举和特征对象,前者限制较多,因此后者往往是最常用的解决办法。

trait Draw {
    fn draw(&self);
}

struct Button {
    id: u32,
}
impl Draw for Button {
    fn draw(&self) {
        println!("这是屏幕上第{}号按钮", self.id)
    }
}

struct Select {
    id: u32,
}

impl Draw for Select {
    fn draw(&self) {
        println!("这个选择框贼难用{}", self.id)
    }
}

fn main() {
    let elems: Vec<Box<dyn Draw>> = vec![Box::new(Button { id: 1 }), Box::new(Select { id: 2 })];

    for e in elems {
        e.draw()
    }
}

以上代码将不同类型的 Button 和 Select 包装成 Draw 特征的特征对象,放入一个数组中,Box<dyn Draw> 就是特征对象。这样只要是实现了Draw特征的类型都能放入Vec中了。

说明dyn

  • 回忆一下泛型章节我们提到过的,泛型是在编译期完成处理的:编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发(static dispatch),因为是在编译期完成的,对于运行期性能完全没有任何影响。

  • 与静态分发相对应的是动态分发(dynamic dispatch),在这种情况下,直到运行时,才能确定需要调用什么方法。之前代码中的关键字 dyn 正是在强调这一“动态”的特点。

  • 当使用特征对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。

其实,特征也是 DST 类型,而特征对象在做的就是将 DST 类型转换为固定大小类型。

Box内存布局

先来看看 Vec<i32> 的内存布局:

(stack)    (heap)
┌──────┐   ┌───┐
│ vec1 │──→│ 1 │
└──────┘   ├───┤
           │ 2 │
           ├───┤
           │ 3 │
           ├───┤
           │ 4 │
           └───┘

之前提到过 Vec 和 String 都是智能指针,从上图可以看出,该智能指针存储在栈中,然后指向堆上的数组数据。

那如果数组中每个元素都是一个 Box 对象呢?来看看 Vec<Box<i32>> 的内存布局:

                    (heap)
(stack)    (heap)   ┌───┐
┌──────┐   ┌───┐ ┌─→│ 1 │
│ vec2 │──→│B1 │─┘  └───┘
└──────┘   ├───┤    ┌───┐
           │B2 │───→│ 2 │
           ├───┤    └───┘
           │B3 │─┐  ┌───┐
           ├───┤ └─→│ 3 │
           │B4 │─┐  └───┘
           └───┘ │  ┌───┐
                 └─→│ 4 │
                    └───┘

上面的 B1 代表被 Box 分配到堆上的值 1。

可以看出智能指针 vec2 依然是存储在栈上,然后指针指向一个堆上的数组,该数组中每个元素都是一个 Box 智能指针,最终 Box 智能指针又指向了存储在堆上的实际值。

因此当我们从数组中取出某个元素时,取到的是对应的智能指针 Box,需要对该智能指针进行解引用,才能取出最终的值:

fn main() {
    let arr = vec![Box::new(1), Box::new(2)];
    let (first, second) = (&arr[0], &arr[1]);
    let sum = **first + **second;
}

以上代码有几个值得注意的点:

Box::leak

Box 中还提供了一个非常有用的关联函数:Box::leak,它可以消费掉 Box 并且强制目标值从内存中泄漏,内心独白“这有啥用啊?”

其实还真有点用,例如,你可以把一个 String 类型,变成一个 'static 生命周期的 &str 类型:

fn main() {
   let s = gen_static_str();
   println!("{}", s);
}

fn gen_static_str() -> &'static str{
    let mut s = String::new();
    s.push_str("hello, world");

    Box::leak(s.into_boxed_str())
}

PS:下文仍然没读懂

在之前的代码中,如果 String 创建于函数中,那么返回它的唯一方法就是转移所有权给调用者 fn move_str() -> String,而通过 Box::leak 我们不仅返回了一个 &str 字符串切片,它还是 'static 生命周期的!

要知道真正具有 'static 生命周期的往往都是编译期就创建的值,例如 let v = “hello, world”,这里 v 是直接打包到二进制可执行文件中的,因此该字符串具有 'static 生命周期,再比如 const 常量。

又有读者要问了,我还可以手动为变量标注 'static 啊。其实你标注的 'static 只是用来忽悠编译器的,(是的,之前学过生命周期的标注不会真正改变生命周期,只是为了编译器更好理解,不犯迷糊)但是超出作用域,一样被释放回收。而使用 Box::leak 就可以将一个运行期的值转为 'static。

使用场景

光看上面的描述,大家可能还是云里雾里、一头雾水。

那么我说一个简单的场景,你需要一个在运行期初始化的值,但是可以全局有效,也就是和整个程序活得一样久,那么就可以使用 Box::leak,例如有一个存储配置的结构体实例,它是在运行期动态插入内容,那么就可以将其转为全局有效,虽然 Rc/Arc 也可以实现此功能,但是 Box::leak 是性能最高的。

总结

Box 背后是调用 jemalloc 来做内存管理,所以堆上的空间无需我们的手动管理。与此类似,带 GC 的语言中的对象也是借助于 Box 概念来实现的,一切皆对象 = 一切皆 Box, 只不过我们无需自己去 Box 罢了。

其实很多时候,编译器的鞭笞可以助我们更快的成长,例如所有权规则里的借用、move、生命周期就是编译器在教我们做人,哦不是,是教我们深刻理解堆栈、内存布局、作用域等等你在其它 GC 语言无需去关注的东西。刚开始是很痛苦,但是一旦熟悉了这套规则,写代码的效率和代码本身的质量将飞速上升,直到你可以用 Java 开发的效率写出 Java 代码不可企及的性能和安全性,最终 Rust 语言所谓的开发效率低、心智负担高,对你来说终究不是个事。

因此,不要怪 Rust,它只是在帮我们成为那个更好的程序员,而这些苦难终究成为我们走向优秀的垫脚石。

参考文献

Rust语言圣经-Box<T>堆对象分配

Rust学习中需要注意的一些点

  • 7
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值