- Published on
Rust入门笔记(十四)
- Authors
- Name
- Et cetera
- 首先关于堆栈内存的一些概念
- 堆栈的性能
- Box 的使用场景
- 特意的将数据分配在堆上
- 避免栈上数据的拷贝
- 将动态大小类型变为 Sized 固定大小类型
- trait 对象
- Box 内存布局
- Box::leak
- 使用场景🎬
- 再说两句关于 Box
- Deref 解引用
- 通过 * 获取引用的值
- 智能指针解引用
- 定义自己的智能指针
- * 背后的原理
- 函数和方法中的隐式 Deref 转换
- 连续隐式 Deref 转换
- Deref 规则总结
- 引用归一化
- 三种 Deref 转换
- DerefMut
- Deref 的总结
首先关于堆栈内存的一些概念
栈内存从高位地址向下增长: 在
Rust
中,main 线程
的栈大小是8MB
,普通线程是2MB
,在函数调用时会在其中创建一个临时栈空间,调用结束后 Rust 会让这个栈空间里的对象自动进入Drop
流程(所以Box
默认实现了Deref
和Drop
trait),最后栈顶指针自动移动到上一个调用栈顶,无需程序员手动干预,因而栈内存申请和释放是非常高效的。堆内存从低位地址向上增长: 堆内存通常只受物理内存限制,而且通常是不连续的,因为堆内存的申请和释放是由程序员手动控制的,所以堆内存的申请和释放是比较耗时的。
Rust
堆上对象还有一个特殊之处,它们都拥有一个所有者,因此受所有权规则的限制:当赋值时,发生的是所有权的转移(只需浅拷贝栈上的引用或智能指针即可)
堆栈的性能
小型数据,在栈上的分配性能和读取性能都要比堆上高
中型数据,栈上分配性能高,但是读取性能和堆上并无区别,因为无法利用寄存器或 CPU 高速缓存,最终还是要经过一次内存寻址
大型数据,只建议在堆上分配和使用
Box 的使用场景
先总结如下:
特意的将数据分配在堆上
数据较大时,又不想在转移所有权时进行数据拷贝
类型的大小在编译期无法确定,但是我们又需要固定大小的类型时
特征对象,用于说明对象实现了一个特征,而不是某个特定的类型
特意的将数据分配在堆上
fn main() {
// 正常创建一个 a 变量,那该变量一定是在栈上分配的
// let a = 3;
// 如果我们想要将 a 变量分配在堆上,那么我们可以使用 Box::new() 函数
let a = Box::new(3);
println!("a = {}", a); // a = 3
// 下面一行代码将报错
// let b = a + 1; // cannot add `{integer}` to `Box<{integer}>`
}
因为智能指针往往都实现了 Deref
和 Drop
trait,所以:
println!
可以正常打印出 a 的值,是因为它隐式地调用了Deref
对智能指针 a 进行了解引用最后一行代码
let b = a + 1
报错,是因为在表达式中,我们无法自动隐式地执行Deref
解引用操作,你需要使用 * 操作符let b = *a + 1
,来显式的进行解引用a 持有的智能指针将在作用域结束(main 函数结束)时,被释放掉,这是因为
Box<T>
实现了Drop
特征
但是上面案例中,我们并没有体会到 Box
的优势,因为 Box
的优势在于数据较大时,又不想在转移所有权时进行数据拷贝,而且将一个简单的值分配到堆上并没有什么太大意义。分配到栈上由于寄存器、CPU 缓存的原因,它的性能将更好,而且代码可读性也更好。
避免栈上数据的拷贝
当栈上数据转移所有权时,实际上是把数据拷贝了一份,最终新旧变量各自拥有不同的数据,因此所有权并未转移。
而堆上则不然,底层数据并不会被拷贝,转移所有权仅仅是复制一份栈中的指针,再将新的指针赋予新的变量,然后让拥有旧指针的变量失效,最终完成了所有权的转移:
fn main() {
// 在栈上创建一个长度为1000的数组
let arr = [0;1000];
// 将arr所有权转移arr1,由于 `arr` 分配在栈上,因此这里实际上是直接重新深拷贝了一份数据
let arr1 = arr;
// arr 和 arr1 都拥有各自的栈上数组,因此不会报错
println!("{:?}", arr.len());
println!("{:?}", arr1.len());
// 在堆上创建一个长度为1000的数组,然后使用一个智能指针指向它
let arr = Box::new([0;1000]);
// 将堆上数组的所有权转移给 arr1,由于数据在堆上,因此仅仅拷贝了智能指针的结构体,底层数据并没有被拷贝
// 所有权顺利转移给 arr1,arr 不再拥有所有权
let arr1 = arr;
println!("{:?}", arr1.len());
// 由于 arr 不再拥有底层数组的所有权,因此下面代码将报错
// println!("{:?}", arr.len());
}
将动态大小类型变为 Sized 固定大小类型
Rust
需要在编译时知道类型占用多少空间,如果一种类型在编译时无法知道具体的大小,那么被称为动态大小类型 DST(Dynamically Sized Type)
其中一种无法在编译时知道大小的类型是递归类型:在类型定义中又使用到了自身,或者说该类型的值的一部分可以是相同类型的其它值,这种值的嵌套理论上可以无限进行下去,所以 Rust 不知道递归类型需要多少空间:
// 递归类型
enum List {
Cons(i32, List),
Nil,
}
递归类型的大小是无法在编译时知道的,因此它是动态大小类型,而 Rust
不允许我们创建动态大小类型的变量,因此下面代码将报错:
fn main() {
let list = List::Cons(1, List::Cons(2, 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
将递归类型的值放在堆上,这样就可以在编译时知道类型的大小了:
fn main() {
// 递归类型
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
}
只需要将 List
存储到堆上,然后使用一个智能指针指向它,即可完成从 DST
到 Sized 类型(固定大小类型)
的华丽转变。
trait 对象
在 Rust
中,想实现不同类型组成的数组只有两个办法:枚举和trait 对象,前者限制较多,因此后者往往是最常用的解决办法。
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 trait
的 trait对象
,放入一个数组中,Box<dyn Draw>
就是特征对象。
其实,trait
也是 DST 类型
,而 trait对象 在做的就是将 DST 类型
转换为 Sized 类型
。
Box 内存布局
# Vec<i32> 内存布局
(stack) (heap)
┌──────┐ ┌───┐
│ vec1 │──→│ 1 │
└──────┘ ├───┤
│ 2 │
├───┤
│ 3 │
├───┤
│ 4 │
└───┘
Vec
和 String
都是 Box
的封装,它们的内存布局如上图所示,Vec
和 String
的 stack
上只有一个指向 heap
的指针,而 heap
上则是实际的数据。
那如果数组中每个元素都是一个 Box
对象呢?来看看 Vec<Box<i32>>
的内存布局:
(heap)
(stack) (heap) ┌───┐
┌──────┐ ┌───┐ ┌─→│ 1 │
│ vec2 │──→│B1 │─┘ └───┘
└──────┘ ├───┤ ┌───┐
│B2 │───→│ 2 │
├───┤ └───┘
│B3 │─┐ ┌───┐
├───┤ └─→│ 3 │
│B4 │─┐ └───┘
└───┘ │ ┌───┐
└─→│ 4 │
└───┘
上面的 B1
代表被 Box
分配到堆上的值 1。
Vec<Box<i32>>
的 stack
上有一个指向 heap
的指针,而 heap
上则是 Box
对象,Box
对象的 stack
上有一个指向 heap
的指针,而 heap
上则是 i32
类型的数据。
vec2
依然是存储在栈上,然后指针指向一个堆上的数组,该数组中每个元素都是一个 Box 智能指针
,最终 Box 智能指针
又指向了存储在堆上的实际值。
因此当我们从数组中取出某个元素时,取到的是对应的智能指针 Box
,需要对该智能指针进行解引用,才能取出最终的值:
// 使用 & 借用数组中的元素,否则会报所有权错误
// 因为数组中的元素是 Box 智能指针,如果不使用 & 借用,那么将会发生所有权转移,即将 Box 智能指针的所有权转移给了 first,那么原来的数组就不再拥有该元素的所有权,因此下面的代码将报错
// 表达式不能隐式的解引用,因此必须使用 ** 做两次解引用,第一次将 &Box<i32> 类型转成 Box<i32>,第二次将 Box<i32> 转成 i32
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::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())
}
如果 String
创建于函数中,那么返回它的唯一方法就是转移所有权给调用者 fn move_str() -> String
,而通过 Box::leak
不仅返回了一个 &str
字符串切片,它还是 'static
生命周期的!
但是真正具有 'static
生命周期的往往都是编译期
就创建的值,例如 let v = "hello, world"
,这里 v
是直接打包到二进制可执行文件中的,因此该字符串具有 'static
生命周期,再比如 const 常量
。
这和手动标注 'static
生命周期的区别在于,手动标注的 'static
生命周期,其实是一个 泛型生命周期
,它可以是任意的生命周期,只不过我们手动标注了 'static
生命周期而已(简单说就是欺骗了编译器),而 编译期
创建的值,其生命周期是真正的 'static
生命周期,它只能是 'static
生命周期,不能是其它生命周期。
使用场景🎬
需要一个在运行期初始化的值,但是可以全局有效,也就是和整个程序活得一样久,那么就可以使用 Box::leak
,例如有一个存储配置的结构体实例,它是在运行期动态插入内容,那么就可以将其转为全局有效,虽然 Rc/Arc
也可以实现此功能,但是 Box::leak
是性能最高的。
再说两句关于 Box
Box
背后是调用 jemalloc
来做内存管理,所以堆上的空间无需我们的手动管理。与此类似,带 GC
的语言中的对象也是借助于 Box
概念来实现的,一切皆对象 = 一切皆 Box, 只不过我们无需自己去 Box 罢了。
Deref 解引用
#[derive(Debug)]
struct Person {
name: String,
age: u8
}
impl Person {
fn new(name: String, age: u8) -> Self {
Person { name, age}
}
fn display(self: &mut Person, age: u8) {
let Person{name, age} = &self;
}
}
以上代码有一个很奇怪的地方:在 display
方法中,self
是 &mut Person
的类型,接着我们对其取了一次引用 &self
,此时 &self
的类型是 &&mut Person
,然后我们又将其和 Person
类型进行匹配,取出其中的值。
为何 Rust
允许将 &&mut Person
跟 Person
进行匹配呢?
这是因为 Rust
中的 Deref
特征,它允许我们在某些情况下,将 &T
类型的值转换成 &U
类型的值,只要 T
类型实现了 Deref
特征,且 Deref
的 Target
类型是 U
类型。
何为智能指针?能不让你写出 ****s
形式的解引用,我认为就是智能: ),智能指针的名称来源,主要就在于它实现了 Deref
和 Drop
特征,这两个特征可以智能地帮助我们节省使用上的负担:
Deref
可以让智能指针像引用那样工作,这样你就可以写出同时支持智能指针和引用的代码,例如*T
Drop
允许你指定智能指针超出作用域后自动执行的代码,例如做一些数据清除等收尾工作
通过 * 获取引用的值
常规引用的解引用,常规引用是一个指针类型,包含了目标数据存储的内存地址。对常规引用使用 * 操作符
,就可以通过解引用的方式获取到内存地址对应的数据值:
fn main() {
let a = 1;
let b = &a;
let c = *b;
println!("{}", c); // 1
}
*b
就是对 b
进行解引用,获取到了 b
指向的内存地址中的数据值。如果用 assert_eq!(1, b)会报错:
error[E0277]: can't compare `{integer}` with `&{integer}` //无法将{integer} 与&{integer}进行比较
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
|
= help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
// 你需要为{integer}实现用于比较的特征PartialEq<&{integer}>
智能指针解引用
常规解引用和智能指针解引用的区别在于,常规解引用是获取到了内存地址中的数据值,而智能指针解引用是获取到了智能指针中的数据值,智能指针中的数据值可能是内存地址,也可能是其它类型的数据。
考虑一下智能指针,它是一个 Struct
,如果你直接对它进行 *myStruct
,显然编译器不知道该如何办,因此我们可以为智能指针结构体实现 Deref trait
。
实现 Deref
后的智能指针结构体,就可以像普通引用一样,通过 * 进行解引用,例如 Box<T>
智能指针:
fn main() {
let x = Box::new(1);
let sum = *x + 1;
}
定义自己的智能指针
让我们一起来实现一个智能指针,功能上类似 Box<T>
。由于 Box<T>
本身很简单,并没有包含类如长度、最大长度等信息,因此用一个元组结构体即可。
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
跟 Box<T>
一样,我们的智能指针也持有一个 T 类型的值,然后使用关联函数 MyBox::new
来创建智能指针。由于还未实现 Deref trait
,此时使用 *
肯定会报错:
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
--> src/main.rs:14:19
|
14 | assert_eq!(5, *y);
| ^^
现在来为 MyBox
实现 Deref trait
,以支持 *
解引用操作符:
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
实现很简单,当解引用 MyBox
智能指针时,返回元组结构体中的元素 &self.0
,有几点要注意的:
在
Deref trait
中声明了关联类型Target
,在之前章节中介绍过,关联类型主要是为了提升代码可读性deref
返回的是一个常规引用,可以被*
进行解引用
* 背后的原理
当我们对智能指针 Box 进行解引用时,实际上 Rust 为我们调用了以下方法:
*(y.deref())
首先调用 deref
方法返回值的常规引用,然后通过 *
对常规引用进行解引用,最终获取到目标值。
至于 Rust 为何要使用这个有点啰嗦的方式实现,原因在于所有权系统的存在。如果 deref
方法直接返回一个值,而不是引用,那么该值的所有权将被转移给调用者,而我们不希望调用者仅仅只是 *T
一下,就拿走了智能指针中包含的值。
需要注意的是,* 不会无限递归替换,从 *y
到 *(y.deref())
只会发生一次,而不会继续进行替换然后产生形如 *((y.deref()).deref())
的怪物。
函数和方法中的隐式 Deref 转换
对于函数和方法的传参,Rust
提供了一个极其有用的隐式转换:Deref
转换。若一个类型实现了 Deref trait
,那它的引用在传给函数或方法时,会根据参数签名来决定是否进行隐式的 Deref
转换,例如:
fn main() {
let s = String::from("hello world");
display(&s)
}
fn display(s: &str) {
println!("{}",s);
}
以上代码有几点值得注意:
String
实现了Deref trait
,可以在需要时自动被转换为&str
类型&s
是一个&String
类型,当它被传给display
函数时,自动通过Deref
转换成了&str
必须使用
&s
的方式来触发Deref(仅引用类型的实参才会触发自动解引用)
连续隐式 Deref 转换
fn main() {
let s = MyBox::new(String::from("hello world"));
display(&s)
}
fn display(s: &str) {
println!("{}",s);
}
这里我们使用了之前自定义的智能指针 MyBox
,并将其通过连续的隐式转换变成 &str
类型:首先 MyBox
被 Deref
成 String
类型,结果并不能满足 display
函数参数的要求,编译器发现 String
还可以继续 Deref
成 &str
,最终成功的匹配了函数参数。
假如 Rust 没有提供这种隐式转换,我们该如何调用 display 函数?可能是这样:
fn main() {
let s = MyBox::new(String::from("hello world"));
display(&(*s)[..])
}
总之,当参与其中的类型定义了 Deref trait
时,Rust
会分析该类型并且连续使用 Deref
直到最终获得一个引用来匹配函数或者方法的参数类型,这种行为完全不会造成任何的性能损耗,因为完全是在编译期
完成。
但是 Deref
并不是没有缺点,缺点就是:如果你不知道某个类型是否实现了 Deref trait
,那么在看到某段代码时,并不能在第一时间反应过来该代码发生了隐式的 Deref
转换。事实上,不仅仅是 Deref
,在 Rust
中还有各种 From/Into
等等会给阅读代码带来一定负担的特征。还是那句话,一切选择都是权衡,有得必有失,得了代码的简洁性,往往就失去了可读性,Golang
就是一个刚好相反的例子。
再来看一下在方法、赋值中自动应用 Deref
的例子:
fn main() {
let s = MyBox::new(String::from("hello, world"));
let s1: &str = &s;
let s2: String = s.to_string();
}
对于 s1
,我们通过两次 Deref
将 &str
类型的值赋给了它(赋值操作需要手动解引用)
;而对于 s2
,我们在其上直接调用方法 to_string
,实际上 MyBox
根本没有没有实现该方法,能调用 to_string
,完全是因为编译器对 MyBox
应用了 Deref
的结果(方法调用会自动解引用)
。
Deref 规则总结
一个类型为 T
的对象 foo
,如果 T: Deref<Target=U>
,那么,相关 foo
的引用 &foo
在应用的时候会自动转换为 &U
。
引用归一化
Rust 编译器
实际上只能对 &v
形式的引用进行解引用操作,那么问题来了,如果是一个智能指针
或者 &&&&v
类型的呢? 该如何对这两个进行解引用?
答案是:Rust
会在解引用时自动把智能指针
和 &&&&v
做引用归一化操作,转换成 &v
形式,最终再对 &v
进行解引用:
把智能指针(比如在库中定义的,Box、Rc、Arc、Cow 等)从结构体脱壳为内部的引用类型,也就是转成结构体内部的
&v
把
多重&
,例如&&&&&&&v
,归一成&v
关于第二种情况,结合一段标准库源码理解:
impl<T: ?Sized> Deref for &T {
type Target = T;
fn deref(&self) -> &T {
*self
}
}
在这段源码中,&T
被自动解引用为 T
,也就是 &T: Deref<Target=T>
。 按照这个代码,&&&&T
会被自动解引用为 &&&T
,然后再自动解引用为 &&T
,以此类推, 直到最终变成 &T
。
三种 Deref 转换
在之前都是不可变的 Deref
转换,实际上 Rust
还支持将一个可变的引用转换成另一个可变的引用以及将一个可变引用转换成不可变的引用,规则如下:
当
T: Deref<Target=U>
,可以将&T
转换成&U
,也就是我们之前看到的例子当
T: DerefMut<Target=U>
,可以将&mut T
转换成&mut U
当
T: Deref<Target=U>
,可以将&mut T
转换成&U
DerefMut
use std::ops::{Deref, DerefMut};
struct MyBox<T> {
v: T,
}
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox { v: x }
}
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.v
}
}
impl<T> DerefMut for MyBox<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.v
}
}
fn main() {
let mut s = MyBox::new(String::from("hello, "));
display(&mut s)
}
fn display(s: &mut String) {
s.push_str("world");
println!("{}", s);
}
以上代码有几点值得注意:
要实现
DerefMut
必须要先实现Deref
特征:pub trait DerefMut: Deref
T: DerefMut<Target=U>
解读:将 &mut T
类型通过DerefMut
特征的方法转换为&mut U
类型,对应上例中,就是将&mut MyBox<String>
转换为&mut String
对于上述三条规则中的第三条,它比另外两条稍微复杂了点:Rust 可以把可变引用隐式的转换成不可变引用,但反之则不行
。
如果从 Rust
的所有权和借用规则的角度考虑,当你拥有一个可变的引用,那该引用肯定是对应数据的唯一借用,那么此时将可变引用变成不可变引用并不会破坏借用规则;但是如果你拥有一个不可变引用,那同时可能还存在其它几个不可变的引用,如果此时将其中一个不可变引用转换成可变引用,就变成了可变引用与不可变引用的共存,最终破坏了借用规则。
Deref 的总结
Deref
可以说是 Rust
中最常见的隐式类型转换,而且它可以连续的实现如 Box<String> -> String -> &str
的隐式转换,只要链条上的类型实现了 Deref
特征。
也可以为自己的类型实现 Deref trait
,但是原则上来说,只应该为自定义的智能指针实现 Deref
。例如,虽然你可以为自己的自定义数组类型实现 Deref
以避免 myArr.0[0]
的使用形式,但是 Rust
官方并不推荐这么做,特别是开发三方库时。