前面介绍了环境配置以及基础语法,掌握之后已经可以开始用Rust编写一些简单的程序了,今天就要来介绍一下Rust核心的功能—内存管理与所有权
1. 关于内存管理
无论什么高级语言必须考虑到的一点就是编写程序时对于内存的管理问题,更简单一点解释,利用编程语言能快速高效的分配内存空间,并且将一些不再使用的变量内存空间进行释放回收而不是让内存无限增长.
目前最常见的应该就是两种管理方式:手动管理和自动管理.其中手动管理的代表就是C,C++,而Java,Go这种带有GC的则属于自动管理内存.当然,这里提到的内存管理主要是针对堆上的内存空间,毕竟栈上的内存空间管理在编译期间就已经完成了,这也是为什么栈上的对象大小确定的原因,这样更易于内存管理.
但是像C或C++这样需要程序员手动进行内存管理的语言,对于开发者的细心程度要求极高,如果忘记delete
,free
又或者是编程时出现“野指针”等,都会对编写出的程序造成严重危害.而带有GC的编程语言虽然降低了开发者的开发难度,但同样有一定损耗.在程序运行时必须花费一些额外的代价对程序内的变量进行追踪来确定是否需要释放,而且释放的及时性没有保证,GC时可能会打断工作线程…
说到底,内存管理其实就是在追踪对象的生命周期,当生命周期结束时进行释放就是最理想的状态,说到底就是一个分配对应一个回收的道理,既不遗漏也不重复回收.根据这个想法Rust抛开了之前的手动内存管理也放弃了GC,转而用所有权和借用规则来保证内存安全.
2. 所有权与借用规则
所有权和借用规则其实原理非常简单,可以总结成下面的几句话
- 在一个作用域内一个值只能有一个所有者.
- 当所有者离开作用域时,这个值就会被丢弃.
- 值可以从一个作用域移动到另一个作用域,但是当前作用域的所有者会失去对值的所有权.
- 值可以被借用,但是借用的生命周期不超过所有者的生命周期.
- 在同一作用域中允许多个不可变借用.
- 在同一作用域中允许至多一个可变借用.
根据这些规则,Rust编译器会在编译期间进行检查,如果存在安全问题那么这段代码是无法通过编译的,换句话说如果你的代码通过了编译那么一定就是内存安全的.而Rust的作用域划分也很清晰,以大括号进行划分.在大括号结束的时候,就可以调用Drop
对作用域中的变量进行释放.
在这里对于资源的初始化与回收,必须提到一个很常见的名词:RAII (Resource Acquisition is Initialization) ,中文翻译资源获取即初始化,字面意思理解就是对象初始化时能导致资源的初始化(获取到),更深层的含义更代表着对象释放时资源也能随之释放,这也是内存安全的重要保障.这种资源释放的思想在很多其他语言其实都有相应的实现,如Python中的with
,Go中的defer
.
有了上面的这些,理解Rust的内存管理就很简单了.Rust并不关心堆上的内存对象,而只在乎栈上拥有它的所有者.擒贼先擒王,无论怎么借用移动或者引用,只要当所有者离开作用域就把对应堆上的内存资源进行释放,保证了对象与资源的生命周期统一.
上面说了这么多,直接来几个例子来解释一下
2.1 移动
demo1
fn main(){
let string_1=String::from("test");
let string_2=string_1;
println!("string_1={}",string_1);
}
在这里编译器给我们提供了一个报错信息,value borrowed here after move
.什么意思呢,代表着字符串的所有权移动给了string_2
,而原本的string_1
就无法再有效访问了.
根据之前提到过的内容,String
类型是动态的,长度可以发生变化,因此它的空间是在堆上分配的,而在栈中只有指向堆中内容的指针.如果let string_2=string_1;
这条语句是对堆中内容进行复制,那么会对性能带来额外的消耗;但如果是指向同一地址,那么当两个变量离开作用域后,会对同一内存空间进行两次释放即二次释放,这也是会造成安全性问题的.因此Rust根据上面的规则,直接将string_1
无效化而将所有权给新的接收变量string_2
,这样既保证了性能也不会造成二次释放,这样的过程称为移动(move)
2.2 拷贝
与上述例子相似的是栈上数据的拷贝
demo2
fn main(){
let x=111;
let y=x;
println!("x={},y={}",x,y);
}
在这里代码与上面的demo类似,却没有任何报错成功编译运行.为什么这里不发生移动?因为i32
整型大小确定存储在栈中,在栈中的拷贝几乎没有性能影响且栈中内存由编译器进行管理,因此这里不需要移动所有权.
延伸一下,Rust中有一个Copy
trait用于存储在栈上的类型,如果类型拥有这个trait那么旧的变量在赋值给新的变量之后依旧可以访问使用.当然如果拥有了Copy
那么就肯定不会拥有Drop
,毕竟Drop
是针对离开作用域需要特殊处理的类型,二者是矛盾的.
2.3 克隆
接着demo1来讲,如果我们在赋值之后依然想访问string_1
呢?这当然也不是没有办法的,我们可以使用clone()
对堆上数据进行复制保证两个变量都能同时访问.
demo3
fn main(){
let string_1=String::from("test");
let string_2=string_1.clone();
println!("string_1={} string_2={}",string_1,string_2);
}
这个熟悉一些其他语言或者写过opencv的开发者应该相当熟悉,类似于opencv中不想修改原始Mat,这里使用clone()
会对堆上的数据进行克隆复制.不过从demo1就知道,一旦使用clone()
就会造成额外的资源消耗,不过也不必太过惊慌,偶尔为了开发效率写出一点“烂代码”也是可以理解的.
2.4 当所有权遇到函数
上面都是在主函数中的demo,如果遇到了函数调用会发生什么呢
fn print_something(message:String){
println!("message is {}",message);
}
fn main(){
let string_1=String::from("test");
print_something(string_1);
println!("string_1={}",string_1);
}
又是熟悉的报错,所有权移动到了函数里,函数结束离开作用域内存就被释放,原本main中的string_1
也就无法访问.怎么解决这个问题,一个最简单的想法就是“有借有还”,我们把传入的变量再作为返回值传给原始变量就好了,这样兜兜转转所有权还是在原变量手中.
fn print_something(message:String)->String{
println!("message is {}",message);
return message;
}
fn main(){
let mut string_1=String::from("test");
string_1=print_something(string_1);
println!("string_1={}",string_1);
}
这样修改后,我们依旧可以在函数调用之后访问string_1
.但是注意这里做的修改,除了函数增加返回值,还必须将string_1
设为mut也就是可变的,也就是说传回来的虽然值内容没变,但是实际已经改变了.这不正是那句话:”看起来你没变,实际你变了”.当然也不一定非要这样,简单点的话,传入一个clone
后的对象也能实现同样功能.
fn print_something(message:String){
println!("message is {}",message);
}
fn main(){
let string_1=String::from("test");
print_something(string_1.clone());
println!("string_1={}",string_1);
}
但是上面也说了,使用clone
会造成额外的开销.那有没有更好的解决办法呢?熟悉C或者C++的立马回答道,使用引用!!!
在这里我们可以传入对象的引用而不是真正的值,这样所有权也就不会转移.
fn print_something(message:&String){
println!("message is {}",message);
}
fn main(){
let string_1=String::from("test");
print_something(&string_1);
println!("string_1={}",string_1);
}
其实,这才是真正意义上的“有借有还”.引用只是设置一个访问变量的指针,传入的也是这个指针,所以不会涉及到所有权的转移,同时原始的变量也不会受影响.相比起之前假的“有借有还”,这里使用之后原封不动的退还称为借用
2.5 可变引用
当我们在调用函数中想根据条件然后修改原始值,如果用上面的引用写出来会是这样.
fn change_string(s:&String){
if s.len()<10{
s.push_str(" len < 10");
}else{
s.push_str(" len > 10");
}
}
fn main(){
let string_1=String::from("test");
change_string(&string_1);
println!("string_1={}",string_1);
}
编译器微微泛红一笑,告诉你借用的东西不能随便更改.
不过编译器也好心提示了,可以将引用设置为mut,也就是可变引用
fn change_string(s:&mut String){
if s.len()<10{
s.push_str(" len < 10");
}else{
s.push_str(" len > 10");
}
}
fn main(){
let mut string_1=String::from("test");
change_string(&mut string_1);
println!("string_1={}",string_1);
}
这就类似于所有者给你权限,允许你更改它所拥有的东西,相当于权限更大的借用.
fn change_string(s1:&mut String,s2:&mut String){
if s1.len()<10{
s1.push_str(" len < 10");
}else{
s1.push_str(" len > 10");
}
if s2.len()<5{
s2.push_str(" len < 5");
}else{
s2.push_str(" len > 5");
}
}
fn main(){
let mut string_1=String::from("test");
change_string(&mut string_1,&mut string_1);
println!("string_1={}",string_1);
}
当我们故意写了一段匪夷所思的代码,原本意思是想对传入的String长度设置两个阈值条件,但是写成了这样,编译器再次报错.
编译器说这里有两次可变借用发生,因此在同一作用域至多只能有一个可变引用,这样就可以防止数据竞争.
3. 总结
到这里,对Rust的所有权特性应该有了大致的感受,和以往的编程语言不同,Rust对编译时格外严格以求运行时顺利.这对于开发者应该也是比较好的体验,看到顺利编译出可执行文件就可以放心下班而不用随时担心测出Bug然后慢慢trace去找Bug的原因.当然这也不是绝对的,毕竟机器之外我们还得与人打交道,哈哈哈.