rust语言基础学习: 使用智能指针Box<T>将数据分配到堆上

rust语言基础学习: 使用智能指针Box<T>将数据分配到堆上

2020-07-13
Rust

昨天学习了Rust中智能指针的概念,智能指针是Rust中一种数据结构,它的表现类似指针,同时有额外的元数据和功能。 大多数智能指针拥有其所指向数据的所有权,智能指针被分配到栈上,指向堆上的数据,实现了堆内存受栈内存生命周期控制,这样Rust通过所有权机制为堆上的值也引入了生命周期。 今天,我们将学习Rust标准库中的智能指针Box<T>

理解Rust的内存管理是学习Rust需要跨过的第一道障碍。 在Rust中,对于编译时能够确定占用内存大小的类型,默认是将这个类型的变量值其分配栈上。 编译时无法确定大小时,变量的内存包含两部分,值被分配到堆上,栈上放一个智能指针指向并拥有堆上的值。例如对于String, Vec<T>这样的结构,因其大小不确定运行时可能会增长,所以数据被放到堆上保存,栈上是拥有堆上数据所有权的智能指针。

Rust默认把编译时能确定大小的值分配到栈上,这点与其他很多编程语言不同;Rust把动态数据(编译时无法确定大小)存储在堆上,堆内存由Rust的所有权机制管理。 这是对前面已经学习的Rust关于内存管理的总结,那么有没有一种方式可以让我们把数据直接保存在堆上呢?看到这里你也许会问,什么样的场景需要我们手动将数据保存到堆上呢? 这里先把场景列举一下:

  1. 当有大量的数据,并且希望在确保数据不被拷贝的情况下转移所有权的时候
  2. 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
  3. 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候

1.智能指针Box的概念和基本使用方法 #

接下来我们先学习智能指针Box<T>的概念和基本使用方法。学完概念和使用方法后再举例看一下Box<T>是如何应用到上面列出的3个使用场景的。

Box<T>是一个十分简单的智能指针,它允许我们将一个值放在堆上而不是放在栈上,留在栈上的则是指向并拥有堆上数据的指针。 使用Box时除了数据被存储在堆上而不是栈上之外,几乎没有什么性能损失。Box是如此简单的智能指针,它也没有提供其他额外的功能。

Box<T>的使用方法十分简单,直接上例子吧。

例1:

1fn main() {
2    let a = 99i32;
3    let b = Box::new(99i32);
4    println!("a = {}", a); // a = 99
5    println!("b = {}", b); // b = 99
6}

例1代码中的第2行,定义了i32类型的变量a,变量a被分配到栈上。第3行定义了变量b,变量b是一个Box<i32>智能指针,指向了堆上的i32值99。 运行程序会正常打印a和b的值。观察后边打印a和b的值的方式,可以看出虽然Box的数据被保存在堆上,但是和访问栈上数据的方式几乎一样。 b拥有堆上数据的所有权,当b离开作用域时,它将会被释放,这个释放过程包含栈上的智能指针以及它所指向的堆上的数据。

从例1中可以看到,使用Box几乎没有什么性能损失,但是这个例子中单独将一个i32分配到堆上是没有什么意义的。 这里只是为了用一个简单的例子演示Box的基本用法,实际情况中还是将单个i32的值存储在栈上更合适。Rust中某个类型的变量默认存储值的地方适用于大多数场景。

2.智能指针Box的使用场景 #

学习了智能指针Box<T>的概念和基本用法,接下来看它的使用场景。

2.1 当有大量的数据并且希望在确保数据不被拷贝的情况下转移所有权时 #

例2:

1fn main() {
2    let x = [0u8; 10];
3    print_len(x);
4    println!("{}", x.len()) // 10
5}
6
7fn print_len(arr: [u8; 10]) {
8    println!("{}", arr.len()) // 10
9}

上面例2的代码很简单也是可以正常运行的:

  • 第2行定义并初始化了一个类型为[u8; 10]的数组变量xx会被分配到栈上。
  • 第3行调用print_len函数,参数arr的类型也是[u8; 10]。从xarr的传参赋值是Copy语义,参数arr也会被分配到栈上,arr中的数据从x的数据复制。Copy语义没有发生所有权转移,在第4行还是可以使用变量x的。

例2的代码十分简单,从这段代码的功能上看也不需要使用智能指针Box<T>

下面我们把例2的代码稍微修改一下,

例3:

1fn main() {
2    let x = [0u8; 4 * 1024 * 1024];
3    print_len(x);
4    println!("{}", x.len())
5}
6
7fn print_len(arr: [u8; 4 * 1024 * 1024]) {
8    println!("{}", arr.len())
9}

上面例3的代码,是可以编译成功的,但是运行的话会出现thread 'main' has overflowed its stack栈内存溢出的错误,从编译的角度分析如下:

  • 第2行定义并初始化了一个类型为[u8; 4 * 1024 * 1024]的数组变量xx会被分配到栈上。
  • 第3行调用print_len函数,参数arr的类型也是[u8; 4 * 1024 * 1024]。从xarr的传参赋值是Copy语义,参数arr也会被分配到栈上,arr中的数据从x的数据复制。Copy语义没有发生所有权转移,在第4行还是可以使用变量x的。

编译没有问题,但是运行时却出错了,因为在栈上分配的x的变量内存时4M,调用print_len函数时,arr在也是在栈上分配也是4M,这两个变量加起来已经是8M了。而线程默认的栈内存是8M吗,所以运行时出了栈内存溢出错误。

例3的问题是在栈上分配了大量数据,耗尽了宝贵的栈内存,在这种情况下我们就需要祭出智能指针Box<T>,改变变量的默认的存储位置。

例3在栈上分配了大量的数据,函数调用传参时的Copy语义在大量数据时效率也不高。 我们尝试使用Box<T>同时避免数据拷贝,并将数据所有权转移到print_len函数内。 正好满足智能指针Box<T>的使用场景1: 当有大量的数据,并且希望在确保数据不被拷贝的情况下转移所有权的时候。

例4:

1fn main() {
2    let x = Box::new([0u8; 4 * 1024 * 1024]);
3    print_len(x);
4    // println!("{}", x.len()) // borrow of moved value: `x`
5}
6
7fn print_len(arr: Box<[u8; 4 * 1024 * 1024]>) {
8    println!("{}", arr.len()) // 4194304
9}

上面例4的代码可以编译和运行,分析如下:

  • 第2行定义并初始化了一个类型为智能指针Box<[u8; 4 * 1024 * 1024]>变量xx的内存分配有两部分,一部分是堆上数组数据大小为4M,另一部分是栈上的Box智能指针,指向并拥有堆上的数据。
  • 第3行调用print_len函数,从xarr的传参是Move语义,堆上数据的所有权从x转移到arr上。x在之后的范围将失效,所以不能在第4行再使用x。

例4通过使用智能指针Box,避免了在栈上分配大量的数据。

注意,从Box的new(x: T)函数的文档的描述中Allocates memory on the heap and then places x into it. This doesn’t actually allocate if T is zero-sized.。 但是下面例5的代码的cargo build编译时debugrelease出来的二进制文件在执行时却有不同的行为。debug的二进制文件在执行时会在栈上先分配8M的内存,就会stack overflow,release的二进制文件在执行时直接在堆上分配。

例5:

 1fn main() {
 2   let x = Box::new([0u8; 8 * 1024 * 1024]); 
 3   println!("{}", x.len())
 4}
 5
 6cargo build
 7./target/debug/foo 
 8
 9thread 'main' has overflowed its stack
10fatal runtime error: stack overflow
11[1]    28446 abort      ./target/debug/foo
12
13./target/release/foo  
148388608

关于例5的代码在rust的github上专门有一个issue,https://github.com/rust-lang/rust/issues/53827

2.2 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候 #

Rust需要在编译时知道一个类型占用多少空间,这样就将其默认分配到栈上。 例如有下面的结构体类型。

例6:

 1struct Node {
 2    value: i32
 3}
 4
 5
 6fn main() {
 7    let node = Node{value: 1};
 8    println!("{}", std::mem::size_of::<Node>()); // 4
 9    println!("{:?}", node);
10    
11}

在例6中,定义一个struct Node,它只有一个i32的value字段,在编译时就能确认Node类型占用内存大小为4字节,所以例6中第7行变量node将被分配到栈上。

下面将Node稍微修改一下,为其增加一个类型同样为Node的parent字段。

例7:

1#[derive(Debug)]
2struct Node { // recursive type `Node` has infinite size
3    value: i32,
4    parent: Node,
5}

例7的代码是无法编译通过的,报了recursive type Node has infinite size编译错误。因为例6中时包含递归类型的Node,且编译时是无法确认其占用内存大小。

这个场景刚好可以使用Box,因为智能指针Box的大小是确认的,考虑parent字段可控,如果将parent字段的类型修改为Option<Box<Node>>,就能够在编译时确认其内存大小了。

例8:

 1#[derive(Debug)]
 2struct Node {
 3    value: i32,
 4    parent: Option<Box<Node>>,
 5}
 6
 7
 8fn main() {
 9    let node = Node{
10        value: 1, 
11        parent: Some(Box::new(Node{value: 0, parent: None}))
12    };
13    println!("{}", std::mem::size_of::<Node>()); // 8
14    println!("{:?}", node);
15}

例8中,使用了Box存储Node的parent的节点,注意Box只是一个智能指针,它将数据分配到堆上,指向堆上的数据,并拥有堆上数据的所有权。使用Box智能指针几乎没有带来任何性能损耗,但在本例中为我们解决了创建无法在编译时确认大小的递归类型的问题。

2.3 当希望拥有一个值并只关心它的类型是否实现了特定trait而不是其具体类型的时候 #

考虑下面的一个需求,要求我们定义一个Record trait,Record trait内有一个record方法,再定义一个函数do_record函数,希望这个函数以Record为参数,而不关心具体实现。 按照其他编程语言中接口的概念,我们可能会写出如下的代码.

例9:

1pub trait Record {
2    fn record(&self);
3}
4
5pub fn do_record(record: Record) {
6    
7}

这段代码是无法编译通过的。因为Rust编译器需要知道每个函数的参数需要占用多少空间,而Record的不同的实现可能占用不同大小的空间。 这里一个简单的方法是将参数类型修改成智能指针Box。因为函数的参数为指向堆的trait指针,需要使用dyn关键字,即Box<dyn Record>

例10:

1pub trait Record {
2    fn record(&self);
3}
4
5pub fn do_record(record: Box<dyn Record>) {
6    record.record();
7}

3.总结 #

本文我们学习了Rust中的智能指针Box<T>的概念和基本使用方法,介绍了三个可以使用Box的场景。 使用Box我们可以将数据分配到堆上,同时利用栈内存帮我们管理堆内存,智能指针在栈上指向并拥有堆上的数据。

回顾一下上节学习智能指针时的内容,智能指针通常使用结构体实现,其区别于常规结构体的显著特征是DerefDrop trait。Box<T>类型作为智能指针,它实现了Deref trait,这样就允许Box<T>值被当作引用对待。当Box<T>值离开作用域时,由于Box<T>类型Drop trait的实现,box所指向的堆数据也会被清除。

基于这个思路,也可以实现我们自己的Box智能指针,作为初学者,先写一下伪代码,进一步加深对Rust内存管理和智能指针的理解:

 1pub struct MyBox<T> {
 2    ptr: 指向堆内存的指针,
 3}
 4
 5impl<T> MyBox<T> {
 6    pub fn new(data: T) -> Self {
 7        1.分配堆内存空间,得到这个空间的地址指针
 8        2.数据data存储到堆内存空间
 9        3.返回MyBox结构体, 包含堆内存空间的指针ptr
10    }
11}
12
13impl<T> Drop for MyBox<T> {
14    fn drop(&mut self) {
15        释放指针指向的堆内存空间
16    }
17}
18
19impl<T> Deref for MyBox<T> {
20    fn deref(&self) -> &T {
21        &**self
22    }
23}

当然这里的伪代码"很傻",只是作为一个初学者按理解简单写了一下基本流程。

智能指针结构体实现Drop trait后,本质是利用栈上结智能指针构体的析构函数drop自动释放堆内存空间。 Box智能指针的本质也是利用栈内存空间的自动管理实现了堆内存空间的自动管理。

参考 #

© 2024 青蛙小白