Rust之抽空学习系列(五)—— 所有权(上)
Rust之抽空学习系列(五)—— 所有权(上)
1、什么是所有权
所有权是确保Rust程序安全的一种机制
- 安全则是指程序中没有未定义的行为
- 未定义的行为是指在执行一段代码时,结果不可预测且未被编程语言指定的情况
- Rust的基础目标是确保程序永远不会有未定义的行为,次要目标是未定义行为应当在编译时被发现而不是运行时
Rust没有如同Java、Python等语言的垃圾回收机制,也不同于C/C++的纯手动内存操作,而是引入了所有权的概念以及相应的工具支持,来保证内存的安全可靠
1.1、初识
内存是计算机内一种有限的资源,因此如果想要编写的程序能够持续高效地在计算机上运行,那么对于内存的管理是不可或缺的
编程语言发展至今,主要有以下三种具有代表性的内存管理方式:
1、C/C++采用的成对的malloc
和free
以及new
和delete
,通过这些API直接操作系统的内存的申请和释放(这样操作的时候,你往往具有较高的权限,然而每一步需要操作者自己管理,效率是比较低下的,也容易犯错)
2、以Java、Python为首的具有垃圾回收机制的语言,它们通常除了程序本身的业务线程外,还会额外增设一个垃圾回收线程(根据既定的垃圾回收算法自动回收内存,牺牲少量的效率,但是保证了开发者的开发效率)
3、接下来要涉及到的Rust独创的所有权机制,依赖于制定的所有权规则进行约束和检查
1.2、所有权规则
既然上面提到了所有权规则,那接下来我们见识下都有哪些规则:
1、Rust中的每一个值都有一个对应的变量作为它的所有者
2、在同一时间内,值有且仅有一个所有者
3、当所有者离开自己的作用域时,它持有的值就会被释放掉
我觉着大概像是这样的关系吧⬆️
1.3、变量作用域
变量的作用域指的是变量在程序中有效的范围
fn main() {
// 未声明,不可用
let s1 = String::from("hello"); // 声明s1,可用
println!("{}", s1); // s1可用
} // 超出作用域,不可用
以上是一段简单的程序,声明一个变量 s1,并且进行输出
main
函数提供了一个代码块,s1就被声明在这个代码块内部,因此这也是 s1 的作用域
在 s1 变量被let
声明前,当前作用域并不知道有s1,因此此时就是开头说的未定义状态,自然是不可用的
接下来,声明了s1,那么作用域知道它的存在,就可以使用 s1了,直到当前代码块结束前,都是有效的
而超出代码块,也就是离开作用域,s1就不可用了,并且Rust会释放它的内存
从变量声明到所处代码块结束,这段区间内便是变量有效的区间
- 变量进入作用域开始,它就是有效的
- 一直持续到离开作用域
1.4、初识 String 类型
接下来,通过一个数据类型String
来更好地了解下所有权的内容
在Rust程序中,String
类型的数据存储在堆上,这是由于很多场景下字符串往往不能在程序运行之前知道其需要多大的内存空间,像那种能够在编译期确定下来的字符串,被称为字符串字面量,本身是不变的
use std::io; //
fn main() {
println!("请输入一些文本:");
// 用来存储输入的文本
let mut input = String::new();
// 从标准输入中读取一行文本,存储在input中
io::stdin()
.read_line(&mut input)
.expect("读取输入时发生错误");
println!("你输入的是:{}", input);
}
像上面这种从用户输入处读取字符串的场景下,用户输什么,输多输少就很难确定了,因此String
在程序运行时申请堆内存进行存储
接下来,看一个更加简单的程序:
// 创建一个String的实例
let str = String::from("风浪越大,鱼越贵");
这里使用String
提供的from()
基于一个字符串字面量创建一个String
实例
可以看到文档的定义,from()
返回的结果会被分配到堆空间
let mut str = String::from("风浪越大,鱼越贵"); // 声明为可变
str.push_str("!"); // 可变的基础上添加内容
println!("{}", str);
使用mut
关键字可以进一步将String
声明为可变的,进而调用push_str()
方法进行字符串的拼接
字符串String
是可变的,而字符串字面量不是,这是由于此二者采用了不同的内存处理方式
1.5、内存与分配
字符串字面量我们能够在编译时就确定其内容,因而这部分硬编码的文本直接嵌入最终的可执行文件,这样访问字符串字面量会非常高效,这是由于字符串字面量的不变性
而对String
类型来说,需要在运行时申请一块内存,先前通过String::from()
的调用发起这个请求
在使用完之后,需要以某种形式将这些内存归还给操作系统,这个对于不同的编程语言有着不同的解决方式,比如Java、Python等采用的是垃圾回收机制,通过一个垃圾回收程序识别不再被使用的内存空间,将它们及时释放,此过程不需要开发人员参与,而C/C++则是由开发者进行手动处理,那么这就需要开发者把握恰当的时机,避免发生难以预测的问题
Rust有一套不同的方案:Rust会在变量离开作用域后释放其持有的内存
在作用域结束的地方,String
类型会自动调用一个名叫drop()
的特殊函数,这其实是实现了Drop
这个trait
(之后再探讨)
我们可以通过断点调试的方式证实一下
在string.rs
里面找到对应Drop
出现的位置打上断点,然后Debug执行
可以看到,程序在执行完main
的内容后停住了,来到了drop
方法执行内存的释放,这便是Rust利用所有权机制进行的资源回收
1.5.1、移动
let a = 2; // a -> 2
let b = a; // b -> a
先来看段简单的代码,根据Rust的类型推导可以了解到a和b会被推导为i32
类型,这是一个赋值的过程
2绑定到变量a上,由于是i32
,大小是确定的,所以会再创建一个变量a的拷贝,绑定到b上,这样a和b的值都是2,这两个变量都被压入栈中,这里就涉及到了栈的内容
此时,a和b是相互独立的
再看另一段代码:
let str1 = String::from("hello");
let str2 = str1;
似乎也是一段赋值,只不过这次换成了String
类型,好像没什么不同,也许也是str2作为str1的拷贝?但是其实事实并非如此
简单表示一下String的内存布局,主要是由3个部分组成:指针(ptr)、长度(len)、容量(capacity),并且这些数据都存储在栈当中
右侧则是表示的存储的字符串的内容,体现了二者之间的绑定关系
len用来记录当前String中使用了多少字节内存,可以看到是5;capacity用来记录String向操作系统总共获取到的内存字节数量,也是5,尽管此时二者的值相等,但是是有区别的
将str1赋值给str2的时候,的确是发生了复制,但是复制的内容是栈里的,因而存储在栈当中的指针、长度、容量字段会在栈上再存在一份(这份便是str2)所对应的
此时,str1和str2中的指针均指向一块堆内存,这与深拷贝的结果是不一样的
如果是深拷贝,则会连同指向的内容一并拷贝一份
但是,如果是深拷贝,在数据足够大时,这种大篇幅的复制会带来很大的性能损耗
现在由于str1和str2两个变量同时指向一块区域,如果其中任意一方离开作用域时,Rust会自动去调用drop()
释放内存,而后者再离开作用域时,将会释放一块已被释放的内存,这便引发了二次释放问题,这将会导致正在使用的数据发生问题,进而埋下安全隐患
那么Rust对于解决这样的问题的手段很彻底,那就是保留一份
在let str2 = str1
执行后,str1自动废弃,也就不再被程序作为一个有效的变量,自然也不需要对其清理
在发生移动后,继续使用变量str1会报错,因为此时str1已经失效了
浅拷贝和深拷贝:
其实,这里的情况与浅拷贝有所区别,栈上内容复制的同时,Rust又将前者无效化,因而新增了移动
的概念,有点类似于str2是str1的接班人;而深拷贝便是将栈上与堆上的数据一并复制的概念
Rust永远不会自动地创建数据的深拷贝,因此,任何自动地赋值操作都将是高效的
1.5.2、克隆
如果说一定需要进行深度拷贝,而不是仅仅复制栈上的数据,那么可以使用一个clone()
方法
let str1 = String::from("hello");
let str2 = str1.clone(); // str1的深拷贝
println!("{}", str1);
println!("{}", str2);
通过调用clone()
,str2把str1栈上和堆上的数据都拷贝了一份,而这可能会相当耗资源
但是对于那些在编译时就已经确定了大小的类型,无论是普通赋值(栈拷贝)或是clone都没有本质上的区别,总是高效的
1.5.3、Copy trait(简单了解)
Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上
如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用,像是i32
这种
Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait
任何不需要分配内存或某种形式资源的类型都可以实现 Copy
如下是一些 Copy
的类型:
- 所有整数类型,比如
u32
- 布尔类型,
bool
- 所有浮点数类型,比如
f64
- 字符类型,
char
- 元组,当且仅当其包含的类型也都实现
Copy
的时候。比如,(i32, i32)
实现了Copy
,但(i32, String)
就没有