Rust 编程实战 - Cell,RefCell, OnceCell

https://www.bilibili.com/video/BV1AAaxeSEHU/


哈喽大家好,这里是曼苏。今天主要是把上一期内容重新录一下,上一期背景音乐有点大,人声都听不清楚。内容主要还是讲内部可变性(interior mutability)。

我们重新创建一个文件,先把原来那个文件改个名字,然后再重新创建一个文件。我们来运行一下看看,好,现在是这个新文件了。

我们要谈的主要是Rust标准库里面的这三个struct:Cell、RefCell和OnceCell。

首先说说什么是interior mutability。简单来说,我们知道在Rust里面,当你这样定义一个变量:

1
let a = 5;

你是不可能再去改变a的值的。只有把它定义成mut才可以改变这个值:

1
let mut b = 5;

在这里你可以改变b的值,但你不能改变a的值。

那么interior mutability就是说,在我把这个变量还是像a的这种定义方法,但是我仍然能够改变它内部的一些值,这个就叫interior mutability。

在Rust的官方文档里,在Cell的文档中已经给了一个例子,很好地说明了这个问题。比如他在官方文档里面的例子是这样的:

1
2
3
4
struct SomeStruct {
regular_field: u8,
special_field: Cell<u8>,
}

当我把special_field定义成Cell这种类型的时候,我们就可以这么做:

1
2
3
4
5
6
let mut val = SomeStruct {
regular_field: 0,
special_field: Cell::new(1),
};

val.special_field.set(100);

这里的val我们没有加mut关键字,但是我们可以去改变这个special_field的值。

我们来运行一下结果看一下。OK,结果都是通过的,就说明这个special_field确实改变了。

这个例子很好地解释了interior mutability这个概念是什么意思。就是我们看到这个SomeStruct,它并不是一个mut类型,但是在后面我们却可以通过Cell这个特殊的包装结构,去改变它的值。

具体来说,这个有什么用呢?因为官方给的这个例子比较抽象,我们可以给一个更贴近生活、更贴近实际的例子。

比如说我们有一个手机型号的结构体,假设你是做一个卖手机的电商网站,里面有很多不同的品牌,每个不同的品牌也有不同的手机型号。那么我们就可以这样定义:

1
2
3
4
5
6
struct PhoneModel {
brand: String,
model_name: String,
release_date: String,
on_sale: Cell<bool>,
}

这里面,brand、model_name和release_date这三个字段一旦定义后就不会再改变。但是on_sale这个字段可能会随着时间的变化而改变。在实际应用中,我们就可以通过interior mutability来定义这样一个数据结构,从而应对实际中会产生的这种情况。

我们可以简单写一个例子:

1
2
3
4
5
6
7
8
let abc_phone = PhoneModel {
brand: String::from("ABC"),
model_name: String::from("D100"),
release_date: String::from("2024-01-01"),
on_sale: Cell::new(true),
};

abc_phone.on_sale.set(false);

我们来运行看一下,全部都通过了,就说明这个时候我们确实改变了这个on_sale值。可以通过这种方法在同样的PhoneModel结构体中,它前面没有mut关键字,但是我依然改变了它内部的一些部分数据。

接下来讲RefCell。RefCell的话,简单来说就是因为你大家去看这个Cell的文档,就会发现它这里面get值,或者我们自己看这个get这个函数,你看它实际上是复制了里面的值出来。你看他这个get他要求这个T是Copy trait,也就是说我每次get的时候,我是把这个Cell里面包的这个值复制了一份出来。

那这样的话对于一些基本类型,这个是没问题的,比如说整型、布尔型、浮点型,这些他是没问题,因为它本身就是Rust的这些基本类型本身就是定义了Copy。那如果说你这个地方是一个非常复杂的、另外一个结构体,比如说你这里是一个HashMap,比如说你这里是一个没有定义Copy这个trait的类型,那你每次去get,然后都要把它复制一份,如果你这个数据量很大,其实这就不是一个很有效的操作。

那这个时候就可以把它定义成RefCell。我们还是用这个PhoneModel的例子来进行说明。比如说我们这里定义一个车的类型:

1
2
3
4
5
struct CarModel {
brand: String,
model_name: String,
parameters: RefCell<HashMap<String, Vec<String>>>,
}

这里的parameters可能包含很多选配,比如不同的颜色、轮胎大小等。

这个时候如果你把它定义成Cell,那就行不通了。我们来看看Cell它能不能进行后续的一些操作:

1
2
3
4
5
6
7
let car_model = CarModel {
brand: String::from("XYZ"),
model_name: String::from("M100"),
parameters: Cell::new(HashMap::new()),
};

car_model.parameters.get().insert(String::from("color"), vec![String::from("red"), String::from("blue")]);

这里会报错,因为HashMap并没有定义Copy这个trait。所以对于这种相对来讲比较复杂的情况,你就不能用Cell了,这个时候就要用RefCell。

1
2
3
4
5
6
7
let car_model = CarModel {
brand: String::from("XYZ"),
model_name: String::from("M100"),
parameters: RefCell::new(HashMap::new()),
};

car_model.parameters.borrow_mut().insert(String::from("color"), vec![String::from("red"), String::from("blue")]);

这样就可以了。

这里面要讲的一个就是这个borrow和borrow_mut。RefCell的borrow checking是在运行时进行的,而不是在编译阶段。也就是说,如果这里有问题,实际上我这里应该会出现错误的提示,但是RefCell的borrow和borrow_mut,它并不是在编译阶段检查的,而是在运行状态时检查的。

我们再来回顾一下这个错误:

1
2
let mut_borrow = car_model.parameters.borrow_mut();
let borrow = car_model.parameters.borrow(); // 这里会在运行时报错

它就会报错说already mutably borrowed。

RefCell的borrow checking还是符合Rust的一些规则,比如说:

  1. 你可以同时有多个不可变借用:
1
2
let borrow1 = car_model.parameters.borrow();
let borrow2 = car_model.parameters.borrow();
  1. 但是你只能有一个可变借用,而且在有可变借用的时候不能有不可变借用:
1
2
let mut_borrow = car_model.parameters.borrow_mut();
let borrow = car_model.parameters.borrow(); // 这里会在运行时报错

最后说一下OnceCell。OnceCell的应用场景是什么呢?它是用于那些初始化需要很长时间的数据结构。比如说你要从磁盘里面读进一个很大的文件,但是其实你这个文件你用到这个文件,你是要看你程序执行的情况。有时候你的程序遇到的情况,未必要读取这个文件。那你当你在初始化的时候,你读取了这个文件,但这个文件在后续又没用到,那这就是一个不是那么有效的操作,你可能耽误了大量的时间去读取那个文件。

OnceCell就是说当你不用它的时候,它就没有初始化,它就是一个相当于是一个空值。当你需要用到它的时候,它才会去进行这个初始化。这有点像其他语言中的lazy loading。

比如说我们有一个很大的数据集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
let big_data = OnceCell::new();

for i in 99..=103 {
let result = process_data(i, &big_data);
println!("Result for {}: {}", i, result);
}
}

fn process_data(n: i32, data: &OnceCell<Vec<i32>>) -> i32 {
if n > 100 {
let big_vec = data.get_or_init(|| {
println!("Initializing big data...");
(0..1_000_000).collect()
});
big_vec.iter().sum()
} else {
0
}
}

在这个例子中,只有当n大于100时,我们才会初始化并使用这个大数组。这样就有效地避免了在不需要使用大数组的情况下初始化它,从而节省了内存和CPU资源。

这就是Cell、RefCell和OnceCell的主要功能和区别。

好,那今天就这样,就先到这里。谢谢大家的关注,下次见。

文章目录