讲动人的故事,写懂人的代码
传统编程语言的堆内存管理机制,一般分为手动内存管理和垃圾回收两种流派。
属于手动内存管理流派的C++,虽然提供了手动管理内存的灵活性,但容易因程序员的失误导致内存泄漏、悬垂指针、双重释放和野指针等问题。
属于垃圾回收流派的Java,虽然省去了手动内存管理,但带来了内存释放不可预知且系统开销较大的问题。
另外,在多线程环境中,多个线程同时访问和修改同一块内存时,可能会发生数据竞争,导致未定义行为或数据损坏。
该如何解决这些问题?Rust的解决方案是实现编译器参与检查的“出域即清”内存自动释放机制。这使Rust成为以内存安全著称的编程语言。
Rust编译器参与检查“出域即清”内存自动释放机制,指当堆上值、栈上值和其他系统资源(如文件句柄)的所有者超出作用域时,Rust会自动释放该值所占用的内存资源(对于大多数类型无须显式编写内存释放代码),或关闭相关资源(需要显式编写资源关闭代码,以便Rust调用)。同时,在编译阶段,通过Rust编译器,尤其是其内部的借用检查器(borrow checker),对代码进行全面分析。它不仅能检查“出域即清”机制的正确应用,还能验证更广泛的所有权和借用规则。这包括检测潜在的内存泄漏、悬垂指针、数据竞争等问题。
通过这种机制,Rust能在编译时发现违背所有权机制规则的源代码,并给出明确的错误提示,要求程序员修改。这样做的目的是将大量可能在运行时出现的bug,消灭在编译阶段,极大地节省了返工成本,提高了程序的内存安全性和并发安全性。
除了编译时检查,Rust还保留了一些必要的运行时安全检查,如数组边界检查,以提供额外的安全保障。这种多层次的安全机制使Rust在保证高性能的同时,大幅度降低内存相关错误和并发问题的风险。
Rust编译器参与检查“出域即清”的内存自动释放机制虽然好处多多,但它涉及变量、堆上值、栈上值、不可变借用和可变借用这5个角色,分别在所有权、所有权移动、作用域、生存期、丢弃和复制这6个方面的30种场景(注意,栈上值与堆上值在作用域方面不适用,所以应该是28种场景)。
如果再把1个适合单线程和多线程开发的智能指针Box<T>,4个适合单线程开发的智能指针Rc<T>、RefCell<T>、Ref<T>、RefMut<T>,以及5个适合多线程开发的智能指针Arc<T>、Mutex<T>、MutexGuard<T>、RwLock<T>、RwLockReadGuard<T>和RwLockWriteGuard<T>,一共11个智能指针都补充到之前的5个角色里,与那6个方面组合起来,那么场景会陡增到66个!
怎么会是66个?16乘以6,应该是96个场景呀。为了减少你的焦虑,我把其中8个智能指针中联系紧密的分为一组。这样8个智能指针就分成了3组。让每组充当一个角色,就能减少场景数量。再减去前面说到的2个不适用的场景,实际共计64个场景。但66比较好记,所以标题就写成66个场景,如表1所示。
表1 Rust所有权机制的66个场景
角色/方面 | 所有权 | 所有权移动 | 作用域 | 生存期 | 丢弃 | 复制 |
---|---|---|---|---|---|---|
变量 | 1. 变量关于所有权场景规则 | 2. 变量关于所有权移动场景规则 | 3. 变量关于作用域场景规则 | 4. 变量关于生存期场景规则 | 5. 变量关于丢弃场景规则 | 6. 变量关于复制场景规则 |
栈上值 | 7. 栈上值关于所有权场景规则 | 8. 栈上值关于所有权移动场景规则 | 9. 栈上值关于作用域场景规则(不适用,因为栈上值本身没有作用域) | 10. 栈上值关于生存期场景规则 | 11. 栈上值关于丢弃场景规则 | 12. 栈上值关于复制场景规则 |
堆上值 | 13. 堆上值关于所有权场景规则 | 14. 堆上值关于所有权移动场景规则 | 15. 堆上值关于作用域场景规则(不适用,因为堆上值本身没有作用域) | 16. 堆上值关于生存期场景规则 | 17. 堆上值关于丢弃场景规则 | 18. 堆上值关于复制场景规则 |
不可变引用 | 19. 不可变引用关于所有权场景规则 | 20. 不可变引用关于所有权移动场景规则 | 21. 不可变引用关于作用域场景规则 | 22. 不可变引用关于生存期场景规则 | 23. 不可变引用关于丢弃场景规则 | 24. 不可变引用关于复制场景规则 |
可变引用 | 25. 可变引用关于所有权场景规则 | 26. 可变引用关于所有权移动场景规则 | 27. 可变引用关于作用域场景规则 | 28. 可变引用关于生存期场景规则 | 29. 可变引用关于丢弃场景规则 | 30. 可变引用关于复制场景规则 |
Box<T> | 31. Box<T>关于所有权场景规则 | 32. Box<T>关于所有权移动场景规则 | 33. Box<T>关于作用域场景规则 | 34. Box<T>关于生存期场景规则 | 35. Box<T>关于丢弃场景规则 | 36. Box<T>关于复制场景规则 |
Rc<T> | 37. Rc<T>关于所有权场景规则 | 38. Rc<T>关于所有权移动场景规则 | 39. Rc<T>关于作用域场景规则 | 40. Rc<T>关于生存期场景规则 | 41. Rc<T>关于丢弃场景规则 | 42. Rc<T>关于复制场景规则 |
RefCell<T> | 43. RefCell<T>关于所有权场景规则 | 44. RefCell<T>关于所有权移动场景规则 | 45. RefCell<T>关于作用域场景规则 | 46. RefCell<T>关于生存期场景规则 | 47. RefCell<T>关于丢弃场景规则 | 48. RefCell<T>关于复制场景规则 |
Arc<T> | 49. Arc<T>关于所有权场景规则 | 50. Arc<T>关于所有权移动场景规则 | 51. Arc<T>关于作用域场景规则 | 52. Arc<T>关于生存期场景规则 | 53. Arc<T>关于丢弃场景规则 | 54. Arc<T>关于复制场景规则 |
Mutex<T> | 55. Mutex<T>关于所有权场景规则 | 56. Mutex<T>关于所有权移动场景规则 | 57. Mutex<T>关于作用域场景规则 | 58. Mutex<T>关于生存期场景规则 | 59. Mutex<T>关于丢弃场景规则 | 60. Mutex<T>关于复制场景规则 |
RwLock<T> | 61. RwLock<T>关于所有权场景规则 | 62. RwLock<T>关于所有权移动场景规则 | 63. RwLock<T>关于作用域场景规则 | 64. RwLock<T>关于生存期场景规则 | 65. RwLock<T>关于丢弃场景规则 | 66. RwLock<T>关于复制场景规则 |
这66个场景形成了Rust陡峭的学习曲线,让很多初学者望而却步。
只有搞清这66种场景,才能翻越陡峭的Rust学习高峰,赶走Rust入门的拦路虎。
在介绍这66个场景之前,先熟悉一下11个角色和6个方面。
Rust的所有权机制涉及多个角色。这些角色可以分为三类,即变量、引用和智能指针。这些角色在不同场景下发挥着各自的作用。
变量是最基本的角色,它拥有栈上值或堆上值。当一个变量离开作用域时,它所拥有的值也随之被释放。
引用则是对变量所拥有的值的借用,分为不可变引用和可变引用。在同一作用域内,要么只能有一个可变引用,要么可以有多个不可变引用。但不能同时存在可变和不可变引用。
智能指针是更高级的抽象,它们在实现上利用了Rust的所有权规则。但提供了如下更灵活方便的使用模式。
Box<T>
提供了堆内存分配,常用于表达递归的数据结构。
Rc<T>
通过引用计数实现共享不可变所有权,适合单线程内表达图数据结构。
RefCell<T>
提供了运行时借用检查,可以在运行时动态检查借用规则,在回调函数这样的场景下,比编译时检查更为灵活。
Ref<T>
和RefMut<T>
是RefCell<T>
的两个关联类型,它们分别代表了RefCell<T>
的不可变借用和可变借用。
Arc<T>
是Rc<T>
的多线程版本。
Mutex<T>
和RwLock<T>
用于多线程内的共享可变访问,其中Mutex<T>
适合写操作较多的场景,而RwLock<T>
适合读操作较多的场景。
MutexGuard<T>
是Mutex<T>
的一个关联类型,代表了对Mutex<T>
的锁定和访问。RwLockReadGuard<T>
和RwLockWriteGuard<T>
是RwLock<T>
的两个关联类型,分别代表了对RwLock<T>
的读锁定和写锁定。
为了给所存储的值起名字,我们需要变量。变量(variable)是用于存储数据的命名空间。
与许多其他编程语言不同,Rust默认情况下变量的值是不可变的,这意味着一旦变量被赋值,它的值就不能再被改变。这个特性有助于提高程序的内存安全性和可预测性。
在Rust中,变量作为值的所有者,遵循所有权规则。每个值在任一时刻只能有一个所有者。当变量离开其作用域时,如果它仍然拥有某个值的所有权,该值会被丢弃,相关的内存(无论是在栈上还是堆上)都会被释放,如代码清单1所示。
代码清单1 Rust 变量可变性与所有权示例
1 fn main() {
2 let x = 5;
3 // x = 10;
4
5 let mut y = 10;
6 y = 20;
7
8 {
9 let z = String::from("hello");
10 println!("{}", z);
11 }
12
13 // println!("{}", z);
14 }
// 运行结果:
// hello
代码清单1解释如下。
第2行:声明了一个不可变变量x
,并将其与值5
绑定。这体现了Rust默认情况下变量的值是不可变的特性。
第3行:如果取消注释,会导致编译错误“cannot assign twice to immutable variable x
”,因为x
是不可变的,不能被重新赋值。
第5行:使用mut
关键字声明了一个可变变量y
。
第6行:对可变变量y
进行重新赋值,这是允许的。
第8-11行:创建了一个新的作用域,并在其中声明并绑定了变量z
。
第9行:z
被与一个String
类型的值绑定,z
成为这个值的所有者。
第11行:作用域结束,z
离开作用域,它拥有的String
值被丢弃,相关内存被释放。这体现了所有权规则和作用域结束时的自动清理。
第13行:如果取消注释,会导致编译错误“cannot find value z
in this scope”,因为z
已经离开作用域,不能再被使用。这再次体现了所有权规则。
Rust的变量拥有值的过程,可以通过所有权转移(如变量赋值、函数调用或函数返回值等)来改变。对于实现了 Copy
trait 的类型,则会进行值的复制而非所有权转移。Rust 还提供了借用机制,允许在不转移所有权的情况下临时使用值。
在C++中,与Rust不同,C++默认情况下变量是可变的。如果想让变量不可变,需要使用const
关键字。
C++没有像Rust那样的所有权系统,但它提供了手动内存管理机制。
C++的变量的生命周期由其作用域决定,当离开作用域时,栈上的变量会自动销毁。
与C++类似,Java变量默认也是可变的。要创建不可变变量,需要使用final
关键字。
对于引用类型,Java变量存储的是对象的引用,而非对象本身。
Java使用自动垃圾回收机制管理内存,无需手动释放。
为了存储局部变量、函数调用信息和在编译时大小已知且固定的值,我们需要栈上值。
栈(stack)是一种快速的内存分配区域,用于存储在编译时大小已知且固定的值。
在Rust中,典型的栈上值包括基本类型(如整型、浮点型、布尔型和字符型)以及包含这些类型的数组和元组。
Rust的栈上值具有以下优势。首先是访问速度快,因为遵循后进先出(LIFO)的原则,栈上值的分配和释放非常快速。其次是自动管理内存,不需要手动分配和释放内存。第三是确定性,栈上值的生命周期在编译时就能确定。
Rust的栈上值有以下劣势。首先是大小限制,栈空间通常较小,不适合存储大量数据。其次是固定大小,栈上值的大小必须在编译时确定,不能动态改变。
Rust的栈上值适用于以下场景。首先是存储小型、固定大小的数据。其次是存储需要快速访问的临时变量。最后是存储函数参数和返回值(当它们是固定大小时),如代码清单2所示。
代码清单2 Rust栈上值示例:基本类型、数组、元组和函数调用
1 fn main() {
2 let integer: i32 = 42;
3 let float: f64 = 3.14;
4 let boolean: bool = true;
5 let character: char = 'A';
6
7 let array: [i32; 5] = [1, 2, 3, 4, 5];
8 let tuple: (i32, f64, bool) = (10, 2.5, false);
9
10 let result = calculate_sum(integer, array[0]);
11 println!("Result: {}", result);
12 }
13
14 fn calculate_sum(a: i32, b: i32) -> i32 {
15 let sum = a + b;
16 sum
17 }
// 运行结果:
// Result: 43
代码清单2解释如下。
第2-5行:展示了Rust中典型的栈上值,包括基本类型(整型、浮点型、布尔型和字符型)。这些都是在编译时大小已知且固定的值。
第7-8行:演示了包含基本类型的数组和元组,它们也是栈上值。
第10行:调用函数calculate_sum
,展示了函数调用信息存储在栈上。参数integer
和array[0]
都是栈上值。
第14行:定义了一个名为 calculate_sum
的函数,接受两个 i32
类型的参数 a
和 b
,并返回一个 i32
类型的值。
第14-17行:calculate_sum
函数定义,展示了函数参数和返回值(固定大小)存储在栈上。sum
是一个局部变量,也存储在栈上。
第16行:函数最后一个不带分号的表达式sum
,就是这个函数的返回值。
与Rust的栈上值相似,C++的栈上值同样包括基本类型、固定大小的数组、结构体和非动态分配的类对象。C++的栈上值也具有快速访问和自动内存管理的优势。通常,C++栈上值的生命周期也是可预测的,基于其所对应的变量的作用域。
C++的栈上值与Rust的栈上值相比存在以下区别。首先是安全性,C++缺乏Rust的所有权系统和借用检查器,可能导致一些内存安全问题。其次是未定义行为,C++允许一些可能导致未定义行为的操作,如返回局部变量的引用,这在Rust中是被禁止的。最后是编译时检查,虽然C++栈上值的生命周期通常可预测,但缺乏Rust那样严格的编译时生命周期检查。
Java的栈上值处理与Rust有显著差异,主要体现在以下方面。
为了存储在编译时大小未知,或在运行时大小可能会改变的值,我们需要堆上值。
堆(heap)是一种动态内存分配区域。堆上值是那些因为在编译时大小未知,或者在运行时大小可能会改变,而需要存储在堆内存上的数据。
在Rust中,通常使用Box<T>
、Vec<T>
、String
等智能指针类型来在堆上分配内存。在Rust中,堆内存的管理方式与C++有很大不同。Rust采用了一种独特的内存管理模型,它既不需要程序员手动管理内存,也不依赖垃圾回收器,而是凭借所有权机制、借用机制、生存期、智能指针、Drop
trait和编译时检查,保证内存安全,同时也实现了高性能。
Rust的堆上值具有以下优势。首先是动态大小,堆允许在运行时动态分配之前未知大小的数据。其次是长生命周期,堆上的数据可以存活超过创建它的作用域。最后是大量数据,适合存储大量数据,而不受栈大小限制。
Rust的堆上值有以下劣势。首先是性能开销,堆分配比栈分配慢,且需要手动或自动的内存管理。其次是缓存效率,堆上的数据可能分散在内存中,影响缓存效率。
Rust的堆上值适用于以下场景。首先是当数据大小在编译时未知时。其次是当需要数据在多个作用域间共享时。最后是实现递归数据结构如链表或树时。如代码清单3所示。
代码清单3 Rust堆上值示例:智能指针与动态数据结构
1 use std::rc::Rc;
2
3 struct Node {
4 value: i32,
5 next: Option<Rc<Node>>,
6 }
7
8 fn main() {
9 let mut vec = Vec::new();
10 vec.push(42);
11
12 let boxed_value = Box::new(100);
13
14 let mut string = String::from("Hello");
15 string.push_str(", world!");
16
17 let node1 = Rc::new(Node {
18 value: 1,
19 next: None,
20 });
21
22 let node2 = Rc::new(Node {
23 value: 2,
24 next: Some(Rc::clone(&node1)),
25 });
26
27 println!("Vec size: {}", vec.len());
28 println!("String content: {}", string);
29 println!("Boxed value: {}", boxed_value);
30 println!("Node2 value: {}", node2.value);
31 }
// 运行结果:
// Vec size: 1
// String content: Hello, world!
// Boxed value: 100
// Node2 value: 2
代码清单3解释如下。
第1行:引入标准库中的Rc
(引用计数智能指针),允许多所有者。
第3行:定义一个结构体Node
,用来表示链表节点。
第4行:结构体中的一个字段value
,类型为i32
,表示节点的值。
第5行:结构体中的另一个字段next
,类型为Option<Rc<Node>>
,表示下一个节点的引用,使用Rc
允许多个节点共享同一个下一个节点。
第5行中的Option
是Rust标准库中的一个枚举,用来表示一个值可能存在也可能不存在的情况。具体来说,Option<T>
定义如下:
enum Option<T> {
Some(T),
None,
}
这个枚举有两个变体:
Some(T)
:表示存在一个值,值的类型为T
。None
:表示没有值。在第5行中,next
字段的类型为Option<Rc<Node>>
,其含义是这个字段可以有两种状态:
Some(Rc<Node>)
:表示存在下一个节点,并且这个节点是通过引用计数智能指针Rc
进行引用的。None
:表示不存在下一个节点,即这是链表的终止节点。使用Option
枚举的好处是,它强制程序员显式地处理可能不存在的值,从而提高代码的安全性和健壮性。例如,在访问next
字段时,必须先检查它是否为Some
,否则会遇到编译错误,这避免了很多空指针异常的潜在问题。
在实际代码中,我们看到第19行node1
的next
字段被设置为None
,表示node1
是链表的终止节点。
而node2
的next
字段被设置为Some(Rc::clone(&node1))
,表示node2
的下一个节点是node1
。
这种设计使得链表节点可以灵活地表示是否有下一个节点,从而实现了更安全和健壮的链表结构。
第9行:声明一个可变的空向量vec
。let
关键字用来声明变量。mut
关键字表示这个变量是可变的,意味着可以对它进行修改操作(例如添加或删除元素)。vec
是变量名,用来引用这个动态数组。
第9行中的Vec
是Rust标准库中的动态数组类型,提供了一个可变长度的序列。Vec
类型的全称是Vec<T>
,其中T
表示向量中元素的类型。在这一行中,Vec
用于创建一个动态数组,可以根据需要添加、删除或访问元素。
Vec::new()
是一个关联函数(即静态方法),用于创建一个新的、空的Vec
。这个函数返回一个空的动态数组,其初始容量为零,但会根据需要自动调整大小。
Vec
类型具有以下特点。
Vec
的长度是可变的,可以根据需要动态增加或减少元素。Vec
在堆上分配内存,并且通常会预留比当前需要更多的空间,以减少频繁的内存分配和复制操作。Vec<i32>
表示存储i32
类型的整数。push
(添加元素)、pop
(移除元素)、len
(获取长度)等。第10行:向向量vec
中添加一个值42
。演示了堆上值的动态大小特性。
第12行:使用Box<T>
在堆上分配一个整数,展示了智能指针的使用。
第14行:将初始值为"Hello"绑定到一个可变字符串变量string
上。
第15行:向字符串string
中追加", world!"。说明了堆上值在运行时可以改变大小。
第17行:创建第一个节点node1
,使用Rc
包装以便在第24行共享所有权,即node2
在第24行和node1
共享这一行所创建的node1
的不可变所有权。
第18行:node1
的value
字段赋值为1
。
第19行:node1
的next
字段赋值为None
,表示这是链表的终止节点。
第22行:创建第二个节点node2
,同样使用Rc
包装。
第23行:node2
的value
字段赋值为2
。
第24行:node2
的next
字段指向node1
,使用Rc::clone
增加引用计数。这展示了如何在多个作用域间共享数据。
第24行中的Rc
代表引用计数(Reference Counting),是一种智能指针,允许多所有者共享同一个数据。当你调用Rc::clone
时,并不会创建数据的副本,而是增加引用计数。这使得多个变量可以安全地共享同一个数据。在这里:Rc::clone(&node1)
会增加node1
的引用计数,而不会复制node1
所指向的Node
实例。这样做的好处是,当你需要多个变量引用同一个数据时,不必担心内存管理问题,Rc
会自动处理这些引用的计数和释放。
第24行中的&node1
是一个引用,表示对node1
的借用。借用的目的是为了只读访问node1
,而不是获取其所有权。具体来说,Rc::clone
需要一个对Rc<T>
的引用作为参数,因此你需要传递&node1
而不是node1
本身。
第17-25行:使用Rc<T>
(引用计数智能指针)创建一个递归数据结构(链表),展示了堆上值适用于实现递归数据结构的场景。
在C++中,堆上值包括使用new
运算符动态分配的对象或数组、标准库容器(如std::vector
、std::string
和std::map
等)以及任何在运行时需要动态分配内存的数据结构。与Rust不同,C++中程序员需要手动管理堆内存(使用delete
释放new
所分配的内存),或使用智能指针如std::unique_ptr
和std::shared_ptr
进行半自动管理。这种方法给予程序员更多控制权,但也增加了内存泄漏和悬垂指针的风险。C++的智能指针提供了类似Rust的所有权语义,但不像Rust那样在编译时强制执行。
C++与Rust关于堆上值有以下区别。首先是内存管理,C++需要手动管理或使用智能指针,Rust使用所有权系统。其次是安全性,C++依赖程序员谨慎性,Rust在编译时强制执行内存安全。最后是性能开销,C++可能因手动管理而略微提高性能,但也增加了出错风险。
在Java中,几乎所有对象都存储在堆上。Java的堆上值包括所有使用new
关键字创建的对象、所有数组(无论是对象数组还是基本类型数组)、所有类的实例,包括String
、集合类(如ArrayList
和HashMap
)等。与Rust和C++不同,Java中堆内存由垃圾回收器自动管理,程序员不需要手动释放内存。这种方法虽然简化了开发,但也带来了垃圾回收不可预知和较大的系统开销,这是Rust刻意避免的。
Java与Rust关于堆上值有以下区别。首先是内存管理,Java使用垃圾回收,Rust使用所有权系统。其次是性能,Java可能因垃圾回收而有性能波动,Rust提供更可预测的性能。第三是资源管理,Java主要关注内存,Rust的所有权系统不仅适用于内存管理,也适用于其他资源(如文件句柄、网络套接字、数据库连接、线程句柄、锁和互斥量等)。最后是并发安全,Java依赖同步机制,Rust的所有权系统在编译时就能防止数据竞争。
(未完待续。划到文章下方能看目录和上下篇哦~😃)
如果喜欢这篇文章,别忘了给文章点个“赞”,好鼓励我继续写哦~😃
如果哪里没讲明白,就在评论区给我留言哦~😃
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。