https://doc.rust-lang.org/std/boxed/struct.Box.html
https://kaisery.github.io/trpl-zh-cn/ch15-01-box.html
在Rust中,Box<T>是一个智能指针,它允许在堆上分配内存。Box是最简单的智能指针之一,它提供了所有权的概念,这意味着Box指向的堆内存会在Box离开作用域时自动被清理,这避免了内存泄漏的问题。
为什么需要Box?
在堆上分配内存:默认情况下,Rust在栈上分配变量的内存。但是栈上的内存分配有大小和生命周期的限制。当你需要一个大小在编译时未知,或者是大量数据,或者是数据需要超出其原始作用域存活时,使用堆内存是更好的选择。
Box提供了一种方式在堆上分配内存。递归类型:在定义递归数据结构时,比如链表,你需要在某种程度上通过指针来间接引用数据。由于Rust需要在编译时知道类型的大小,而递归类型的大小无法在编译时确定(因为它们可以无限嵌套),使用
Box可以让我们定义这样的类型。所有权转移和类型抹除:
Box允许你将所有权从一个作用域转移到另一个作用域,并且可以用于类型抹除,让我们能够在运行时处理不同类型的值。
如何使用Box?
使用Box非常直接,你只需要将数据放入Box中,这样数据就会被分配到堆上。这里有一些基本的使用示例:
在堆上分配内存
1 | fn main() { |
在这个例子中,整数5被分配在堆上,而b是一个指向堆上数据的Box。
定义递归类型
定义一个递归的链表:
1 | enum List { |
在这个例子中,我们定义了一个枚举List,它是一个递归类型。每个Cons变体都持有一个整数和一个指向下一个元素的Box。使用Box允许我们在编译时知道Cons的大小,因为Box的大小在编译时是已知的。
小结
Box是Rust中实现在堆上分配内存的一种方式,它提供了自动的内存管理功能,使得开发者不需要手动管理内存释放。Box在定义递归类型、进行所有权转移,以及在需要堆分配但具体大小未知的场景中特别有用。通过使用Box,开发者可以编写出既安全又高效的Rust代码。
还有什么其他的智能指针?
在Rust中,除了Box<T>,还有其他几种常用的智能指针,主要包括Rc<T>、Arc<T>和RefCell<T>,它们各自有不同的用途和特性:
1. Rc<T>
- 用途:
Rc<T>,即引用计数(Reference Counted)指针,用于在堆上分配内存,允许数据有多个所有者。当最后一个引用离开作用域时,数据会被清理。 - 特性:
Rc<T>只能用于单线程场景。当你需要在程序的多个部分之间共享只读数据时,Rc<T>非常有用。 - 限制:不能在多线程环境中使用,因为
Rc<T>不是线程安全的。
1 | use std::rc::Rc; |
2. Arc<T>
- 用途:
Arc<T>,即原子引用计数(Atomic Reference Counted)指针,与Rc<T>类似,但是它是线程安全的,可以在多线程环境中共享数据。 - 特性:通过使用原子操作来管理引用计数,
Arc<T>允许在程序的多个线程之间共享数据。 - 场景:当你需要在多个线程之间共享所有权,但不需要在任何给定时刻修改数据时,
Arc<T>是一个好选择。
1 | use std::sync::Arc; |
3. RefCell<T>
- 用途:
RefCell<T>提供了内部可变性(interior mutability)模式,即允许你在数据结构内部改变数据,即使在使用不可变引用时也是如此。 - 特性:
RefCell<T>在运行时而不是在编译时执行借用检查,它允许通过.borrow()和.borrow_mut()方法以不可变或可变的方式借用内部数据。 - 场景:当你需要在外部看似不可变的情况下修改数据,或者当编译时借用规则太过严格而不能表达你的设计时,
RefCell<T>是非常有用的。
1 | use std::cell::RefCell; |
总结
Box<T>:单一所有权,堆分配。Rc<T>:多重所有权,单线程,堆分配。Arc<T>:多重所有权,线程安全,堆分配。RefCell<T>:内部可变性,运行时借用检查。
这些智能指针各有用途,它们提供了不同的内存管理策略和所有权模型,使得Rust程序员可以根据具体需求选择合适的工具来管理内存和数据共享。
充一点。如果是Copy类型的数据,Cell和RefCell都可以使用,但是Cell比RefCell性能要好,所以自己看着选
让我们讨论一下Rust中self和RefCell智能指针的使用。首先,Cell<T>只能存储实现了Copy特性的数据类型,而RefCell<T>则可以存储任何类型的数据。这意味着即使是没有实现Copy特性的自定义结构体也可以存储在RefCell<T>中。例如,如果我们有一个不具备Copy特性的自定义结构体,我们可以将它存储在RefCell<T>中,但不能存储在Cell<T>中。RefCell<T>提供了set方法用于修改内部值,以及get方法用于获取内部值。
RefCell<T>特有的是其提供的borrow_mut方法,允许我们在运行时对内部数据进行可变借用,并执行例如加一之类的操作。然而,这也意味着在同一时刻,数据只能被借用为可读或可写,但不能同时进行。
接下来,我们来看一个例子。假设我们定义了一个变量x,并希望在同一作用域内对其进行两次可变借用,按照Rust的借用规则,这会导致编译错误,因为在同一作用域内不允许存在多个可变借用。这种情况下,尝试执行的println!操作会因为编译错误而无法执行。
另一个例子是,当我们使用RefCell<T>封装变量时,虽然按照常规逻辑,相同的操作应该会导致编译时错误,但是我们发现代码仍然可以编译通过。这是因为RefCell<T>在运行时而不是编译时检查借用规则。这意味着,即使我们违反了借用规则,代码仍然可以执行,直到运行时的检查发现规则违反并抛出错误。
这展示了RefCell<T>与传统方法的一个关键区别:它们可以通过编译,但如果违反了借用规则,仍然会在运行时报错。这种机制的存在主要是为了解决编译器在处理复杂项目时可能出现的局限性。即使项目中的某些部分看似没有错误且应该可以正常运行,编译器可能仍会阻止代码的编译。使用RefCell<T>可以在一定程度上绕过这一限制,允许代码编译通过,但在运行时如果存在实际的错误,仍然会被捕获并报告。这种方式主要用于处理编译器可能无法准确判断的复杂借用场景。
Rust中Cell、RefCell与Rc都是什么?有何区别?分别在什么场景下使用? 请举例说明
在Rust中,Cell、RefCell和Rc是三种不同的智能指针类型,用于在Rust的所有权和借用规则中实现更灵活的内存管理。它们各自解决不同的问题,并在不同的场景下使用。
Cell
Cell类型提供了一种在运行时通过内部可变性(mutability)来改变存储在Cell中的值的能力,即使这个Cell被不可变地借用。Cell只能用于Copy类型,这意味着它内部的值在被修改时是通过拷贝和替换的方式进行的。
使用场景
Cell通常用于当你有一个不可变的结构体但需要改变其内部某个字段的值时。比如,在无法使用可变引用的情况下,但又需要修改存储的值。
例子
1 | use std::cell::Cell; |
RefCell
RefCell也提供了内部可变性,但它支持对非Copy类型的值进行修改,并在运行时检查借用规则(相对于编译时)。通过RefCell,你可以借用不可变或可变的引用到内部值,并在运行时执行借用规则的检查。
使用场景
RefCell适用于需要在运行时改变或借用值但又想保证安全性的场景。它允许在一个不可变的引用中进行可变借用,但如果违反了借用规则(例如,同时存在多个可变引用),程序会在运行时导致panic。
例子
1 | use std::cell::RefCell; |
RefCell
RefCell也提供了内部可变性,但它支持对非Copy类型的值进行修改,并在运行时检查借用规则(相对于编译时)。通过RefCell,你可以借用不可变或可变的引用到内部值,并在运行时执行借用规则的检查。
使用场景
RefCell适用于需要在运行时改变或借用值但又想保证安全性的场景。它允许在一个不可变的引用中进行可变借用,但如果违反了借用规则(例如,同时存在多个可变引用),程序会在运行时导致panic。
例子
1 | use std::cell::RefCell; |
Rc
Rc是一个引用计数的智能指针,用于在多个所有者之间共享数据。当最后一个引用被丢弃时,数据会被自动清理。Rc只能用于单线程场景。
使用场景
Rc适用于需要多个所有者共享数据但数据不需要被修改的场景。当你需要在几个部分之间共享只读数据时,Rc是一个好选择。
例子
1 | use std::rc::Rc; |
总结
Cell和RefCell都提供了内部可变性,但Cell适用于Copy类型,而RefCell提供运行时借用检查,适用于需要可变性的更复杂场景。Rc提供了在单线程中的数据共享能力,适用于需要多个所有者的情况。
正确选择这三种类型中的哪一种取决于你的具体需求:是否需要内部可变性、是否需要支持非Copy类型、以及是否需要跨多个所有者共享数据。
我已经按照您的要求整理了内容,使其更加合理通顺,同时保留了所有原有内容,没有进行省略或总结。以下是整理后的内容:
Rust杂谈: Box::new会发生拷贝吗?
大家好,今天给大家来分享一个小话题,就是Box的new会发生拷贝吗。Rust的Box,它是和C++的unique_ptr很像的,它是一种独占的智能指针。我们在Rust中,通常用Box::new来实例化一个指针。
比如说下面这个例子,我们先定义了一个i32的x赋值为1024,然后我们通过Box::new函数,就可以创造出一个Box
Box的new函数,它是由编译器内部实现的。它标准库的实现中,其实只有一个占位逻辑。在比较新的Rust的版本中,Box::new的函数,它其实是有用了#[rustc_box]。当然它上面是有一个#[lang = “box_new”]的一个特殊的注解,也就是这个new是由编译器内部实现的,这里只是个占位。在一些老版本的Rust中,Box::new的实现是下面这样子的,它通过了一个box的关键字来实现这个过程。
和C++的new相比,Rust中这个Box::new的逻辑非常的不透明。在C++中,我们可以通过new关键字将一个对象分配到堆上。这个new实际上做了两个动作:首先是调用operator new去分配一块内存空间,随后在这段内存空间上调用构造函数去初始化整个对象。此外C++中还提供了placement new,它可以在一个既定的内存上去直接调用构造函数来初始化整个对象。C++给了程序员足够的自由,去管理内存的分配与释放,对象的构造和析构。但是在Rust中并没有placement new,也没有构造函数。
我们看一下下面这个例子,我定义了一个叫做BigStruct结构体,这个结构体的成员是一个非常大的数组。在我的main函数中,我们首先定义了一个x,其实就是一个这样的数组,它的值都是零,很明显它是在栈上分配的。然后我们通过Box::new将这个x转移到堆上,得到一个智能指针。我们知道,栈是栈,堆是堆,我们在栈上定义的这个BigStruct,是不可能凭空变到堆上去的。或者说Rust中有什么奇技淫巧,可以完成这次转换吗?很明显这是不可能的。
但这样的话实际上有一个隐藏的问题,我们的一个线程,它的栈大小是有限制的,比如说在Linux上,它默认就是8MB。如果我们想在堆上分配一个数组,按照当前的逻辑,我们就必须先在栈上构建出来。如果我想构建的这个数组超过8MB呢?那这个栈就没办法放下这个数组,那这个程序怎么运行呢?
我相信很多人都有类似的疑问,就是这个Box::new的工作原理是什么。如果真的像之前所说的,在调用Box::new分配到堆上之前,必须在栈上构建出来,那么必然就会受到线程栈大小的限制。Rust是怎么处理这个问题的呢?
这里就不给大家卖关子了,直接说结论吧。Rust把这个问题交给了LLVM做优化。根据测试结果,在不带任何优化的版本中,它的确会在栈上构建,然后复制到堆上。因此这个结构如果太大的话,它的确会爆栈,导致程序异常崩溃。如果开启了优化,Rust的编译器一般会直接在堆上分配内存并初始化,就跳过了在栈上初始化和一次拷贝的动作。当然我这里说了一个”一般”,原因是之前有一些issue,即使在已开启了优化的版本中,这个优化并没有生效。
这边我给大家来演示一下这个例子。我们通过cargo run,不带任何参数去运行一下这个程序,也就是不带优化的debug版本。可以看到这个程序运行起来后就崩溃了,原因是致命的错误:栈溢出了。由此可见,在debug版本,我们通过Box::new在堆上分配一个大结构体的时候,它必然会在栈上先定义出来,然后将其拷贝到堆上。
我们再试一下cargo run –release,也就是使用release版本进行优化。可以看到这个程序是正常运行的,也就是编译器跳过了在栈上分配的这个动作。
我们这里额外去借助一下godbolt的在线反汇编网站,来看一下我们的程序。我们这的BTS是8MB,然后我们在栈上定义了一个8MB的数组,然后我们通过Box::new将其分配到堆上。我们看一下它生成的汇编代码是怎么样的。
首先我们这里指定的是优化级别是0,也就是我们的-O0。它的优化级别最高是应该是O3吧,O0的话就是代表没有任何优化。这里我还额外设置了打开frame pointer,也就是我们每个函数开头的push rbp, mov rbp, rsp。这个不是必要的。
我们简单阅读一下这个test_box的实现。前两行的话就是切换栈帧,不多说。rsp的话则是我们的堆栈指针,堆栈的话它是从高地址往低地址走的。这里我们把rsp给备份到r11寄存器中,然后我就用r11去减去一下这个数值。这个数值实际上是16MB,也就是为我们的数组分配空间。
这里大家可能就会有一个疑问了,这个数组明明不是8MB吗,为什么他这里有把栈空间往下扩展了16MB呢?其实是因为这个数组是一个Copy语义的。Rust中的数组,它可能是Copy的,也可能是Move的,它的具体是哪个呢,是取决于它的元素类型。如果它的元素类型是Copy的,那么整个数组也就是Copy的。如果它的元素类型是Move的,那么整个数组也就是移动语义的。我们这里的数组类型是u8,明显这个是u8是Copy语义的。在调用Box::new的时候,它需要把这个u8给Copy一份,作为这个new的参数,因此它就需要两个8MB,也就是16MB。
我们这边往下走,这里找到第一个函数调用,也就是memset。它的两个参数分别是esi和edx。这边把esi给清空掉,就是异或自己,其实就是把自己置零了。而这个edx设置成8MB。其实就是memset的参数,就相当于说把这个数组,这个8MB的数组全部置为零。这个没什么好讲的,原因是我们这边指定的是零。
在memset后,我们调用了一次memcpy。这个memcpy的话则是将这个u8拷贝到Box::new的这个参数中,也就是我们的16MB中的另外一个8MB。随后我们调用了一次__rust_alloc,就是在堆上分配空间嘛。它的返回值是保存在rax中的,这边的话把rax保存到这个[rbp - 0x18]中,也就这个内存地址处。随后我们通过jmp跳到这个.LBB0_3这里。
可以看到,我们这边再调了一次memcpy。很明显这个memcpy是将Box::new中的这个数组的参数,也就是第二个8MB的内容拷贝到堆上的这一个8MB中去。由此可见,在debug版本情况下,它的确会在堆栈上分配空间,而且这里还分配的是16MB。这种做法有爆栈的风险,并且还存在着不小的拷贝开销。
我们把这个优化级别改成-O1再试一下。我这边它重新生成了汇编代码,我们找到这个标红的这一块。它其实上做的很好。这个网站就是我们左边鼠标悬停在哪一块的话,右边对应的汇编代码它会高亮的。在开启优化后,那个对rsp进行扩展的动作很明显就没了,它上来就调用了__rust_alloc去堆上分配空间。
这里test rax, rax其实检查一下这个返回值,返回的这个指针是不是空的。如果是空的话,就跳到这个.LBB0_3,这调用的是handle_alloc_error。就是如果当我们的__rust_alloc函数返回一个0的时候,返回空指针的时候,说明内存分配失败了,那么它就会调用这个handle_alloc_error。如果返回成功的话,其实它就是同样的先把esi给清空,然后调用memset去把堆上的这8MB给清空掉。
大家有兴趣的话可以在这个网站上看一下。注意,这里是需要一条println!的。如果我们这里把这个println!给注释掉,再看一下它的生成的汇编代码,你会发现它什么都没有做。原因是,编译器觉得你在栈上先定义一个这么大的数组,然后又在堆上分配,但实际上也没有任何效果,编译器就直接把它优化掉了。
这里我们回到我们的PPT。这里再讲一点其他的东西,就是在开发过程中,我们往往需要在debug版本去做测试。但是我们知道在debug版本,它会确实会存在大对象分配爆栈的问题。但是有的时候我们的确是需要分配大对象的。通过以下两种方式,我们可以间接的在堆上生成大数组,但是又不需要在栈上先定义出来。这种情况下可以有效的避免爆栈的发生。
box编译到llvm ir的过程
compiler/rustc_mir_build/src/build/expr/as_rvalue.rs#L120
我会按照您的要求整理这段内容,保持原文的完整性和顺序,不省略内容,不做总结。以下是整理后的版本:
【编程】内存堆栈、装箱与拆箱,以及Rust语言中的Box
不同于数据结构中讨论的堆和栈,堆内存和栈内存是应用程序在运行时可以访问的两种内存区域。
栈内存由编译器管理,存放函数调用时的参数、局部变量、返回值等信息。在函数被嵌套调用时单向增长,在函数返回时释放。这个特征也导致栈区无法存放动态大小的内存。函数被调用的时候申请了多少内存,就要在函数返回时释放多少内存。如果函数的返回值中包含了局部变量的指针或引用,那么在函数返回之后,这个指针或引用就会失效。而Rust程序员就不需要有这样的担心,因为返回局部变量的引用压根没法写生命周期,过不了编译,从而可以在编译期避免返回无效的引用。
不同操作系统对栈区的大小有不同的限制。如果超出了栈内存的限制,就会Stack Overflow。但是限制大小也使操作系统可以分配一块连续的内存给栈区,所以访问起来速度快、效率高。
堆内存则由开发者自行管理,比如使用C++中的new和delete,在运行时动态分配内存。通常来说,在编译期无法确定大小的数据类型需要使用堆上的内存。比如Rust中的String、Vec以及各种容器类型,就需要使用堆内存来保存动态数据,在栈上面只保存堆内存的地址,也就是指针。毕竟指针的大小是固定的,可以在编译期确定。
堆内存几乎没有大小限制,但是动态的分配导致不同内存块在物理上可能是不连续的。并且开发者在动态申请内存时,操作系统可能需要找到一块未使用且足够大的内存,然后交给程序,不能像栈内存一样无脑的单向增长。所以堆内存通常被视为生命周期长但是速度慢的内存区域。
有些时候,我们需要把栈上面的值保存到堆上,或者把堆上面的值保存到栈上。对这些操作,我们有两个专业的名词来形容他们:
Boxing(装箱):狭义来说,意思是把栈上面的值保存到堆上,并在栈上面保存堆内存的指针。广义来说,不一定是把值保存到堆上,只要在栈上面保存某个值的指针,就可以算作装箱了。另一种理解方式是把一个变量保存为它实现过的接口类型,就好像我们有一个箱子,任何满足要求的变量都可以塞进这个箱子。当然,它的底层可能也是基于指针来实现的。
与Boxing相对的就是Unboxing(拆箱):狭义来说,意思是通过栈上面保存的堆内存的指针,把堆上面的内存拷贝到栈上。广义来说略,大家可以自行思考。
通过装箱,我们可以把值类型封装为引用类型。比如默认情况下,int类型通常会按值传递给被调用的函数,也就是会产生一个副本。在函数中,修改参数的值并不会影响原本的值。但是我们可以把int装到一个箱子里,再传递给被调用的函数。这里的箱子其实是一个指针,在函数中,我们可以通过指针访问并修改原本的int变量,从而通过参数来修改函数外的变量。
对于中文语境下Rust语言的初学者来说,智能指针Box这个名字看起来真的有点抽象。但是根据我们前文的介绍可以看出,Rust语言中的Box其实就是一个箱子,用来实现装箱的效果。在Rust中,特指指向堆内存的指针。
Rust作为一个让开发者尽可能掌控底层行为的语言,所有的装箱操作都需要我们手动完成。但是Rust的设计已经使绝大多数变量只需要在栈内存就可以完成计算任务,所以其实开发者很少有使用Box手动装箱的情况。如果需要传引用,那就直接传引用就可以了。
那么Box还有什么用呢?这里举两个例子:
第一个例子是递归struct类型,比如链表中的节点。因为Rust需要struct在编译期有明确的大小,从而可以在栈上面创建struct,而不是在堆上。而递归struct的大小是无法在编译期确定的。我们可以使用Box做一个装箱,毕竟Box只是一个指针,指针的大小是确定的,那么递归struct的大小就可以在编译期确定。当然这并不是最好的实现,因为Box会把值放到堆内存,效率可能不高,并且会出现第一个节点在栈上,其他节点都在堆上的情况。所以在Rust语言中,实现一个链表可不像其他语言那么简单,甚至复杂到可以出一本书。
另一个使用Box的例子是实现类似多态的效果,把不同数据类型存到同一个容器中。如果我们直接这么写,编译器会告诉我们动态类型无法在编译期确定大小。为了解决这个问题,我们可以套一个Box,毕竟指针的大小是确定的,这样就可以通过编译了。当然这也要求先把对象放到堆内存上,才能加入容器。
视频的最后,我们来看一下Rust中的Box是怎么申请堆内存的。从源码可以看到,非常的amazing啊,Box的new函数竟然在递归调用自己。其实分配堆内存是一个需要编译器底层来实现的功能,并不能直接在(标准库)源码中体现。毕竟有些功能是必须要编译器开洞才能实现的。如果要深入研究,就要看看编译器的源码,而不是标准库的源码了。
OK,以上就是本期视频的全部内容。这里是DT,一个分享编程知识和脑洞项目的频道,欢迎一键三连加关注,我们下期再见。
以下是整理后的内容,保留了原文的所有信息,并使其更加通顺合理:
堆内存和栈内存是应用程序在运行时可以访问的两种内存区域,与数据结构中讨论的堆和栈不同。
栈内存:
- 由编译器管理
- 存放函数调用时的参数、局部变量、返回值等信息
- 在函数被嵌套调用时单向增长,在函数返回时释放
- 无法存放动态大小的内存
- 函数返回时,包含局部变量指针或引用的返回值可能会失效
- Rust程序员不需担心这点,因为返回局部变量的引用无法通过生命周期检查,编译会失败
- 不同操作系统对栈区大小有限制,超出限制会导致Stackoverflow
- 限制大小使操作系统可以分配一块连续的内存给栈区,访问速度快、效率高
堆内存:
- 由开发者自行管理(如C++中的new和delete)
- 在运行时动态分配内存
- 用于存储编译期无法确定大小的数据类型(如Rust中的String、Vec等容器类型)
- 栈上只保存堆内存的地址(指针)
- 几乎没有大小限制
- 不同内存块在物理上可能不连续
- 分配时需要找到未使用且足够大的内存块
- 通常被视为生命周期长但速度慢的内存区域
Boxing(装箱)和Unboxing(拆箱):
- Boxing(装箱):
- 狭义:把栈上的值保存到堆上,并在栈上保存堆内存的指针
- 广义:在栈上保存某个值的指针
- 另一种理解:把变量保存为它实现过的接口类型
- Unboxing(拆箱):
- 狭义:通过栈上保存的堆内存指针,把堆上的内存拷贝到栈上
- 广义:与Boxing相对的操作
装箱的作用:
- 把值类型封装为引用类型
- 允许在函数中通过参数修改函数外的变量
Rust中的Box:
- 实现装箱效果,特指指向堆内存的指针
- Rust需要手动完成装箱操作,但大多数变量只需在栈内存完成计算任务
- 开发者很少需要使用Box手动装箱
Box的用途:
- 递归struct类型(如链表节点):
- 使用Box可以在编译期确定递归struct的大小
- 不是最佳实现,可能效率不高,且会导致第一个节点在栈上,其他节点在堆上
- 实现类似多态的效果:
- 允许把不同数据类型存到同一个容器中
- 通过Box解决动态类型在编译期无法确定大小的问题
Rust中Box申请堆内存的实现:
- Box的new函数在递归调用自己
- 实际分配堆内存需要编译器底层实现,无法直接在标准库源码中体现
- 深入研究需查看编译器源码,而非标准库源码
原文链接: https://dashen.tech/2017/03/04/Rust中Box的使用/
版权声明: 转载请注明出处.