Published on

Rust入门笔记(十四)

Authors
  • avatar
    Name
    Et cetera
    Twitter

首先关于堆栈内存的一些概念

  • 栈内存从高位地址向下增长: 在 Rust 中,main 线程的栈大小是 8MB,普通线程是 2MB,在函数调用时会在其中创建一个临时栈空间,调用结束后 Rust 会让这个栈空间里的对象自动进入 Drop 流程(所以 Box 默认实现了 DerefDrop 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}>`
}

因为智能指针往往都实现了 DerefDrop 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 存储到堆上,然后使用一个智能指针指向它,即可完成从 DSTSized 类型(固定大小类型) 的华丽转变。

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()
    }
}

以上代码将不同类型的 ButtonSelect 包装成 Draw traittrait对象,放入一个数组中,Box<dyn Draw> 就是特征对象。

其实,trait 也是 DST 类型,而 trait对象 在做的就是将 DST 类型 转换为 Sized 类型

Box 内存布局

# Vec<i32> 内存布局
(stack)    (heap)
┌──────┐   ┌───┐
│ vec1 │──→│ 1└──────┘   ├───┤
2           ├───┤
3           ├───┤
4           └───┘

VecString 都是 Box 的封装,它们的内存布局如上图所示,VecStringstack 上只有一个指向 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 并且强制目标值从内存中泄漏

一些之前的关于 Box::leak

举个 🌰把一个 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 PersonPerson 进行匹配呢?

这是因为 Rust 中的 Deref 特征,它允许我们在某些情况下,将 &T 类型的值转换成 &U 类型的值,只要 T 类型实现了 Deref 特征,且 DerefTarget 类型是 U 类型。

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

  • 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 类型:首先 MyBoxDerefString 类型,结果并不能满足 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 官方并不推荐这么做,特别是开发三方库时。