前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >66个让你对Rust又爱又恨的场景之二:不可变引用

66个让你对Rust又爱又恨的场景之二:不可变引用

原创
作者头像
程序员吾真本
发布2024-07-19 11:23:08
220
发布2024-07-19 11:23:08
举报
文章被收录于专栏:Rust奇幻之旅:从Java和C++开启

讲动人的故事,写懂人的代码

1.4. 可多方只读借用的不可变引用

在Rust中,相比多方为了读取一份数据,而费尽周章地复制整个数据或转移所有权,有时运用不可变借用会更高效,所以我们需要不可变引用。

不可变引用(immutable references,也称为共享引用)是Rust中一种借用数据的方式,它允许你在不获取所有权的情况下,读取数据但不能修改它。在Rust中,不可变引用用 &T 表示,其中 T 是被引用的类型。这种机制提高了程序的安全性和并发性。

不可变引用具有以下优势。首先是安全性,防止数据竞争,因为多个不可变引用可以同时存在,在方便使用的同时,不用担心数据会被篡改。其次是共享,允许多个部分的代码同时访问数据,而不需要复制。最后是性能,避免了不必要的复制,提高了效率。

不可变引用具有以下劣势。首先是灵活性,不能通过不可变引用修改数据。其次是学习曲线,对新手来说可能需要一些时间来适应这个概念。

不可变引用适用以下场景。首先是当需要读取数据但不需要修改它时。其次是在函数参数中,当函数只需要读取而不需要修改传入的数据时。如代码清单4所示。

代码清单4 Rust中不可变引用的多线程共享与函数传递示例

代码语言:javascript
复制
1  use std::thread;
2  use std::sync::Arc;
3  
4  fn main() {
5      let data = Arc::new(vec![1, 2, 3, 4, 5]);
6  
7      let data_clone1 = Arc::clone(&data);
8      let handle1 = thread::spawn(move || {
9          let ref1 = &*data_clone1;
10         println!("Thread 1: {:?}", ref1);
11         // *ref1[0] = 10;
12     });
13 
14     let data_clone2 = Arc::clone(&data);
15     let handle2 = thread::spawn(move || {
16         let ref2 = &*data_clone2;
17         println!("Thread 2: {:?}", ref2);
18         // ref2.push(6);
19     });
20 
21     let ref3 = &*data;
22     println!("Main thread: {:?}", ref3);
23     // ref3.clear();
24 
25     handle1.join().unwrap();
26     handle2.join().unwrap();
27 
28     print_sum(&data);
29 
30     println!("Original data: {:?}", data);
31 }
32 
33 fn print_sum(numbers: &Vec<i32>) {
34     let sum: i32 = numbers.iter().sum();
35     println!("Sum: {}", sum);
36     // numbers[0] = 100;
37 }
// 运行结果:
// Main thread: [1, 2, 3, 4, 5]
// Thread 1: [1, 2, 3, 4, 5]
// Thread 2: [1, 2, 3, 4, 5]
// Sum: 15
// Original data: [1, 2, 3, 4, 5]

代码清单4解释如下。

第1行:导入标准库中的 thread 模块,用于创建和管理线程。这体现了不可变引用的优势之一,即提高了程序的并发性。

第2行:导入标准库中的 Arc模块,用于多线程环境中的共享不可变所有权。这体现了不可变引用的优势之一,即允许多个部分的代码同时访问数据,而不需要复制。

第5行:创建一个包含整数vector的Arc实例,Arc允许多个线程安全地共享这个数据。vec![1, 2, 3, 4, 5]是要共享的数据。vec![1, 2, 3, 4, 5] 是 Rust 中用于创建一个包含元素 [1, 2, 3, 4, 5] 的动态数组vector的宏。这个宏简化了创建 Vec 的过程,使得代码更加简洁和易读。在创建完vector后,又用Arc包装它,这样就创建了一个包含整数vector的 Arc实例,并与变量data绑定,以便在多个线程之间安全共享数据vec![1, 2, 3, 4, 5]

vec! 宏是创建 Vec 的便捷方法。宏会自动推导元素类型并初始化 Vec

[在C++中,与Rust的Vec类型最相似的概念是 std::vectorstd::vector 是标准模板库(STL)中的一个动态数组类型,提供了动态调整大小、随机访问和类似数组的功能。]

[在Java中,与Rust的Vec类型最相似的概念是 ArrayListArrayListjava.util 包中的一个动态数组类,提供了动态调整大小、随机访问和类似数组的功能。]

第7行:克隆Arc,增加引用计数,以便第一个线程可以持有一个指向相同数据的引用。这体现了不可变引用的优势之一,即允许多个部分的代码同时访问数据,避免了不必要的复制,提高了效率。

Arc::clone 接受一个不可变引用 &data 作为参数,克隆 Arc,生成一个新的 Arc 实例 data_clone1,指向&data所不可变借用的相同的数据。Arc::clone 只需要读取 Arc 的引用计数和指向的数据地址,并不需要修改 Arc 实例本身,因此使用不可变引用即可。使用不可变引用可以保证在调用 clone 方法时,原 Arc 实例不会被修改,符合 Rust 的安全性和并发模型。

生成新的 Arc 实例 data_clone1后,就可以在不同线程中共享该数据。这增加了引用计数,但不复制实际数据。

这背后的含义是什么?先解释一下Arc的工作原理。

当我们创建一个Arc<T>时,Rust在堆上分配了两块内存。一块用于存储T类型的实际数据,另一块用于存储引用计数。

引用计数是一个整数,初始值为1。每次克隆Arc时,这个计数就会原子地增加1。当Arc被丢弃时,计数减1。 当我们调用Arc::clone(&data)时,Rust只复制指向上述两块内存的指针,原子地增加了引用计数,但没有复制T类型的实际数据。

克隆Arc的操作非常快,因为它只涉及指针复制和原子操作,而不会发生大量数据的复制,这在处理大型数据结构时特别有益。

当最后一个Arc被丢弃(引用计数降为0)时,T类型的数据才会被释放。这确保了只要还有Arc存在,数据就不会被释放。

Arc使用原子操作来修改引用计数,这使得它在多线程环境中是安全的。多个线程可以同时持有同一数据的Arc,而不会导致数据竞争。

Arc<T>只提供对T的共享(不可变)访问。如果需要可变访问,通常会使用Arc<Mutex<T>>Arc<RwLock<T>>

这种机制允许多个线程高效地共享同一份数据,而不需要进行昂贵的数据复制操作。它是Rust实现高效且安全的并发编程的关键工具之一。在我们的代码中,这意味着所有线程都在操作同一份数据,而不是各自的副本,这既节省了内存,又保证了数据的一致性。

第8行:使用 thread::spawn 创建并启动了一个新的线程,并将 data_clone1 的所有权移动到该线程的闭包中。

thread::spawn 是 Rust 标准库中的一个函数,用于创建一个新线程,并在该线程中执行一个闭包(closure)。线程是并发编程中的一个基本单位,允许同时执行多个任务。

move 关键字用于将闭包中的所有变量捕获为所有权。这意味着闭包会获得这些变量的所有权,而不是借用它们。在这里,movedata_clone1 的所有权移动到新线程中,以确保数据在新线程中是有效的。

|| 表示一个闭包的参数列表。在这个例子中,参数列表是空的,因为闭包不需要任何输入参数。{ 表示闭包的主体部分开始。闭包是一个可以捕获其环境中变量的匿名函数。

此处为何需要move

Rust 的所有权机制确保每个值都有一个唯一的所有者。在当前作用域结束时,所有者会自动清理资源。当我们在线程中使用数据时,数据的所有权必须被移动到线程内,以确保线程能合法地访问和使用该数据。

Arc允许多个线程共享同一个数据,但每个线程必须持有一个有效的 Arc 实例。如果不使用 move,新线程将无法获得 Arc 实例的所有权,这可能导致线程在运行时无法访问数据或者访问已被释放的数据。

如果没有move会怎样?

Rust 编译器会检查闭包捕获的变量的生存期。如果没有 move,闭包将尝试借用(引用)外部变量 data_clone1。在 thread::spawn 中,闭包必须是 'static,这意味着闭包中引用的数据必须在整个程序生存期内有效。而在没有 move 的情况下,data_clone1 只在 main 函数的生存期内有效。因此,编译器会报错,指出闭包中引用的变量的生存期不足以满足要求。

另外,新线程可能在主线程结束后继续执行。如果数据不被移动到新线程,新线程可能会引用已被释放的数据,导致悬垂指针问题。

什么是'static

在 Rust 中,'static 生存期是一个特殊的生存期,它表示数据可以在程序的整个生存期内有效。理解这个概念对于多线程编程尤其重要,因为线程可能在主线程结束后继续运行,因此在线程中使用的数据必须确保在整个线程生存期内有效。以下是对 'static 生存期的详细解释。

'static 是 Rust 中最长的生存期,表示数据在程序的整个生存期内都是有效的。任何拥有 'static 生存期的数据都可以在程序的任何部分安全地使用。

'static 生存期适用于常量和静态变量,因为这些数据在程序的整个运行期间都存在。例如,字符串字面量(如 "hello")具有 'static 生存期,因为它们存储在程序的只读数据段中,直到程序退出才会被释放。

当我们在 thread::spawn 中创建一个新线程时,传递给它的闭包必须是 'static。这意味着闭包捕获的数据和变量必须在整个线程生存期内有效。这是为了防止线程在运行时访问已经无效或被释放的数据,从而导致未定义行为或程序崩溃。

为什么需要 'static?

首先是因为线程生存期的不确定性。新线程的执行时间和主线程的执行时间可能不一致。新线程可能在主线程结束后仍然运行。如果闭包中捕获的数据不是 'static,那么在主线程结束并释放这些数据后,新线程将无法安全地访问这些数据。

其次是因为数据安全性。Rust 的所有权和生存期机制确保内存安全。要求闭包是 'static 保证了新线程中的数据在其整个生存期内是有效的,防止悬垂指针和数据竞争。

如何实现 'static?

为了满足 thread::spawn 的要求,我们通常使用 move 关键字将闭包中的所有变量捕获为所有权。这样可以确保这些变量的生存期和线程一致。

第9行:创建一个不可变引用ref1,指向data_clone1。这里的&*data_clone1解引用了Arc,然后借用数据。

&*data_clone1中,& 表示取不可变引用。* 是解引用操作符,用于获取 Arc 内部的数据。data_clone1 是一个 Arc 类型,它内部持有一个 Vec<i32>。使用 *data_clone1 可以得到这个 Vec<i32>,然后再使用 & 取得这个vector的不可变引用。

组合起来,&*data_clone1 表示通过 data_clone1 获得 Vec<i32> 的不可变引用。由于 Arc 提供了共享所有权,因此多个线程可以同时读取数据,而不会发生数据竞争。

第10行:打印第一个线程中的数据。{:?} 是一个格式说明符,用于调试打印。它会调用数据类型的 Debug trait,实现该 trait 的数据类型可以用 {:?} 打印出来。

第11行:如果取消这行的注释,将导致编译错误,因为这里尝试修改不可变引用。

第14行:与第7行类似,克隆Arc,以便第二个线程可以持有一个指向相同数据的引用。

第15行:与第8行类似,创建并启动第二个线程。move关键字表示该线程获取其环境中的所有权。

第16行:与第9行类似,创建一个不可变引用ref2,指向data_clone2。这里的&*data_clone2解引用了Arc,然后借用数据。

第17行:与第10行类似,打印第二个线程中的数据。

第18行:如果取消这行的注释,将导致编译错误,因为这里尝试向不可变引用的Vec添加元素。

第21行:创建一个不可变引用ref3,指向主线程中的数据。这里的&*data解引用了Arc,然后借用数据。

第22行:打印主线程中的数据。

第23行:如果取消这行的注释,将导致编译错误,因为这里尝试通过不可变引用清空Vec

第25行:等待第一个线程完成。join方法会阻塞当前线程直到目标线程终止。unwrap确保如果线程发生错误,程序会崩溃并显示错误信息。

handle1 是在第8行创建的线程句柄(thread handle)。当我们调用 thread::spawn 创建新线程时,返回一个 JoinHandle 类型的值,存储在 handle1 中。这个句柄可以用来控制和操作该线程,例如等待线程完成。

joinJoinHandle 类型的方法,用于阻塞当前线程,直到被调用的线程(即 handle1 所代表的线程)完成其执行。换句话说,调用 join() 会让主线程等待 handle1 所代表的线程完成,然后继续执行后续代码。

join 方法返回一个 Result 类型,表示线程的运行结果。Result 是 Rust 中处理可能失败操作的标准类型。 Result 有两个变体。一个是Ok(T) 表示操作成功,包含成功值。另一个是Err(E) 表示操作失败,包含错误信息。unwrapResult 类型的方法,用于获取 Result 中的成功值。如果 ResultOk,则返回内部的值;如果是 Err,则程序会在此处崩溃,并打印错误信息。

为什么需要 join()?

首先是确保线程完成。join() 确保 handle1 所代表的线程完成其执行。只有当该线程执行完毕后,主线程才会继续执行后续的代码。这是为了避免主线程提前结束,从而导致新线程中的任务没有完成。

其次是数据一致性。join() 可以确保数据在并发操作中的一致性。在调用 join() 之后,我们可以确定该线程已经完成了所有对共享数据的读取操作。

最后是防止程序崩溃。如果 join() 不被调用,当主线程结束时,所有子线程也会被强制终止,可能导致未完成的任务和数据损坏。

在这段代码中,handle1.join().unwrap(); 用于等待 handle1 所代表的线程完成。这确保了线程1中的 println!("Thread 1: {:?}", ref1); 执行完毕,然后主线程才会继续执行第26行等待线程2结束。最终,主线程继续执行第24行以后的代码,打印主线程的结果和调用 print_sum 函数。

第26行:等待第二个线程完成。

第28行:调用函数print_sum,传递对数据的不可变引用。

第30行:打印原始数据,确认数据未被修改。

第33行:定义函数print_sum,参数是一个指向整数vector的不可变引用。

第34行:计算vector中所有整数的和。

第35行:打印计算的和。

第36行:如果取消这行的注释,将导致编译错误,因为这里尝试在此函数中修改传入的不可变引用。

C++中最接近Rust不可变引用的概念是常量引用(const reference)。它们都允许读取数据但不允许修改,并且不涉及所有权转移。然而,C++的常量引用与Rust的不可变引用还有以下区别。

首先,Rust的所有权系统和借用检查器在编译时严格检查引用的有效性,防止悬垂引用和数据竞争,而C++则缺乏这种机制,安全性不如Rust。

其次,C++的常量引用可能存在空引用,需程序员小心处理,而Rust的不可变引用总是有效的,空引用在编译时会报错。

最后,Rust通过生存期参数在函数签名中明确引用的有效期,C++没有这种语法,引用的生存期容易混淆。

尽管有这些区别,C++的常量引用在避免复制开销和保证数据不被修改方面,与Rust的不可变引用有类似的优点。

Java中最接近Rust不可变引用的概念是final变量。然而,它们在以下方面存在明显区别。

首先,Java的final只能修饰变量不能重新赋值,但对象内部状态仍可变,而Rust的不可变引用意味着引用的数据完全不可变。

其次,Java缺乏Rust那样的所有权系统和借用规则,final变量虽不可重新赋值,但存在对象内部状态被多处代码同时修改的风险,不能严格防止数据竞争。

第三,Java的垃圾回收减轻了程序员管理内存的负担,但牺牲了一些性能,而Rust通过所有权和借用实现了内存安全和高效。

最后,在多线程访问方面,Java需借助synchronized等机制保证final变量的线程安全,而Rust的不可变引用默认就是线程安全的。

(未完待续。划到文章下方能看目录和上下篇哦~😃)

如果喜欢这篇文章,别忘了给文章点个“赞”,好鼓励我继续写哦~😃

如果哪里没讲明白,就在评论区给我留言哦~😃

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.4. 可多方只读借用的不可变引用
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档