rust语言基础学习: 内存管理复习, 编译时静态检查和运行时动态检查
2020-07-16
昨天学习了内部可变性模式和智能指针RefCell<T>,RefCell是一个在运行时而不是在编译时执行借用规则检查的类型,如果不满足借用规则将会panic。 今天把最近一段时间学习的内容简单梳理复习一下。
Rust在解决内存管理问题时,使用了以下两个套路:
- 对于绝大多数场景,通过编译时的静态检查,保证了效率和安全性。在编译时进行静态检查的优势是这些错误将在开发过程的早期被捕获,同时对运行时没有性能影响,因为所有的分析都提前完成了。
- 对于小部分特殊场景,通过运行时的动态检查,虽然会牺牲一部分性能,但这样可以灵活应对编译时的静态检查无法处理的一小部分特殊场景.
1.编译时的静态检查 #
最近这段时间我们学习了Rust的所有权规则、编译器在编译时根据所有权规则进对内存的使用进行检查,这个是Rust语言独特的内存管理方式。Rust有以下3条所有权规则:
- Rust中的每个值都有一个所有者(owner)
- 一个值在同一时刻只能有一个所有者(move)
- 当所有者离开作用域时,其拥有的值将被丢弃(drop)
Rust使用了Move语义和Copy语义来保证单一所有权。Move语义对应上面第2条所有权规则。Copy语义时针对实现了std::marker::Copy trait的类型,如果一个类型实现了这个Copy trait,那么这个类型的变量赋给这个类型的其他变量时,就会使用Copy语义。 可以从Copy trait的文档https://doc.rust-lang.org/std/marker/trait.Copy.html#implementors中查看标准库中的哪些类型实现了Copy trait。 Copy语义下对应的值会被按位拷贝(浅拷贝),产生新的值。
Drop trait决定了所有者离开作用域时,其拥有值的堆内存是如何被清理的。实现Drop trait的类型要实现一个drop函数,当其所有者变量离开作用域时就会自动调用该方法。例如std::vec::Vec就实现了Drop trait。 实现Drop trait的类型要实现一个drop函数,当其所有者变量离开作用域时就会自动调用该方法。Rust不允许自身或其任何部分实现了Drop trait的类型使用Copy trait。
可以使用Borrow语义在变量对值的所有权不发生转移的前提下,借给其他变量使用。要使用Borrow语义,需要先使用引用语法(&
或&mut
)创建一个引用,在Rust中创建引用的行为被称为借用。
Rust中创建的引用只是拥有值的临时使用权,而没有所有权。使用借用时,编译器会要求必须遵守如下的借用规则:
- 引用不能超过值的生命周期
- 在同一时刻一个值不能创建多个可变引用
- 在同一时刻一个值已经创建了不可变引用,不能再为其创建可变引用
上面借用规则2和借用规则3可理解为一个值在同一时刻只能有一个活跃的可变引用,例如一个可变引用创建之后,用它修改了值,在之后的生命周期内没有再用它修改值,则在之后的那段时间内它可以被认为是不可变引用。
引用不能超过值的生命周期,Rust编译器中有一个借用检查器(borrow checker),通过比较作用域来确保所有引用都是有效的。 有些情况下的引用的生命周期是可以根据代码的上下文推断出来的,还有些情况是无法推断的,需要使用生命周期注解进行标注,为借用检查器提供更多的信息。加上生命周期注解之后,并不会改变引用的生命周期长短。生命周期注解描述了多个引用生命周期相互的关系。例如函数的参数和返回值加上生命周期注解后,描述的是参数与参数之间、参数与返回值之间的关系,不会改变原有生命周期。 Rust将Rust程序员最常用的生命周期注解编写模式整合到Rust的编译器中,这样在这些模式下,编译器中的借用检查器就可以自动推断出生命周期,而不再强制我们显式的添加生命周期注解。被整合进Rust编译器的引用分析模式被称为生命周期省略规则:
- 规则1: 每一个是引用的参数都有它自己的生命周期参数
- 规则2: 如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数
- 规则3: 如果方法有多个输入生命周期参数并且其中一个参数是
&self
或&mut self
, 那么self的生命周期会赋予所有输出生命周期。
静态生命周期是Rust中一种特殊的生命周期,Rust中的全局变量(静态变量)、常量(Const)、字符串字面量具有静态生命周期,贯穿整个程序进程的生命周期。
Rust期望在绝大多数场景下使用上面介绍的所有权模型来自动管理内存。Rust中类型的大小(Size)是决定Rust内存管理十分重要的因素,对于编译时能够确认占用内存大小的类型的变量,Rust默认是将其分配到栈上的。
对于编译时不能确认占用内存大小类型的变量,数据将会被分配到堆上保存。数据被分配到堆上后,同时在栈上创建指向堆上数据的智能指针。即变量是栈上的智能指针,指向堆上的数据,智能指针具有堆上数据的所有权,当其离开作用域时,堆上的数据就会自动释放。
Rust通过在栈上放一个智能指针,指向堆上的数据,通过所有权机制保证了堆上数据的内存受栈上内存(智能指针)生命周期的控制。
String
和Vec<T>
其实就是智能指针。
智能指针是一个在Rust经常被使用的通用设计模式,使用智能指针Box<T>
可以将数据分配到堆上,即使T
类型是被默认分配到栈上,Box<T>
也会将其分配到堆上。有以下三个使用Box<T>
的场景:
- 当有大量的数据,并且希望在确保数据不被拷贝的情况下转移所有权的时候
- 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
- 希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候
2.运行时的动态检查 #
Rust在解决内存管理问题时,还有一小部分编译时静态检查无法处理的特殊场景,为应对这一小部分场景,Rust绕过了编译时的静态检查,只在运行时的做动态检查。当然这在运行时会牺牲一部分性能,但带来了更多的灵活性。
编译时静态检查的优势是可以在开发过程的尽早捕获错误,同时对运行时没有性能影响,因为所有的分析都提前完成了。因此这是Rust的默认行为,是大部分情况的最佳选择。 而运行时的动态检查的好处是允许一部分特定的内存安全的场景,这些场景是编译时静态检查不允许的,运行时的动态检查适合程序员自己确信代码遵守规则,而编译器不能理解和确定的时候。
目前,我们学习了两个运行时动态检查的场景:
- 在需要一个值有多个所有者的场景时,需要使用类似
Rc<T>
这种带有引用计数的智能指针,绕开编译器在编译时对单一所有权的规则检查 - 在需要绕开编译时关于借用规则的静态检查时,需要使用
RefCell<T>
这种智能指针
注意,在编译时静态检查的所有权模型下,堆内存的生命周期和创建它的栈内存(所有者)的生命周期保持一致。为了绕开编译时静态检查,Rust必须提供一种方式,可以分配不受栈内存生命周期控制的堆内存,这样才能绕过编译时的静态检查。
Rust提供的是Box::leak()
,例如Rc内部使用了Box::leak将分配的堆内存泄露,以绕开编译器对所有权规则的检查,对于使用Box::leak泄露出来的堆内存将不再受栈内存的自动管理,而是在运行时通过对引用计数的检查,确保在满足条件时堆内存得到释放。