27 KiB
title | tags | categories | keywords | date | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
聊聊Rust中的所有权机制 |
|
|
Rust,所有权,转移,引用,借用,类型系统,生命期,不可变 | 2022-03-15 15:30:22 |
Rust是一门新颖的系统编程语言,与传统的C/C++不同的是,Rust提供了更加完善和安全的内存管理机制。这也是Rust语言被熟知的天然内存安全和可靠并发的特征。Rust在实现这一系列特征的基础就在于其基于所有权(Ownership)、转移(Move)和借用(Borrow)机制打造的类型系统。但是这套机制系统的引入,却被很多人认为极大的提升了Rust的学习曲线。
所有权
所有权的问题其实是每个编程语言都会遇到的问题。当一个变量被声明,或者一个值被移入内存的时候,这一段内存的所有权就有了归属。从这里开始,这一段有归属的内存就开始了它的生命周期。与其他的系统编程语言一样,Rust也提供编程者控制每个值生命周期的方法,并会在编程者的控制下,结束与释放相应的内存资源。所以所有权实际上就是这一段内存被哪一个变量所引用或者指向,换句话说就是这一段内存被哪一个变量或者指针所拥有。
C/C++中,不仅是并发编程中会存在内存安全问题,就连普通的单线程呈现都是有可能遇到内存安全问题的。这个风险主要来自于悬垂指针。悬垂指针的形成非常容易,只要有多于一个指针指向了同一块内存区域,然后其中的一个指针又释放了这块内存区域,那么其他的指针就全部都会变成悬垂指针。这是因为这些指针已经指向了一块未知的区域,程序是否会继续正常执行,就已经完全交给了运气。如果我们是在一个单线程程序中制造了悬垂指针,那么一般来说想要纠正它还是比较容易的;但是在多线程编程的情况下,就不是那么容易了。
而对于Java来说,任何NPE(NullPointerException
)的出现,也都预示着可能变量所引用的对象已经被垃圾回收器回收掉了,这种情况下要完成的纠错就要更加复杂一些了。
在了解了这个概念以后,再来理解Rust中为什么对所有权做出的限制性规定能够从根基上保证Rust天然的并发安全就非常容易了。
Rust中对于所有权的限制规则是每一个值只有一个能够决定其生命周期的所有者,当这个所有者被释放的时候,其所拥有的值也将被同时清除。从这个概念推导出去,Rust中赋值的概念实际上就是转移非复制类型的值,let a = 10;
的语句的含义就变成了将变量a
与值10
绑定,也就是将值10
的所有权交给了变量a
。但是当再次使用let
进行变量之间的绑定操作的时候,例如let b = a;
,就会将值10
的所有权从变量a
移交给变量b
,在这个语句执行结束后,变量a
就不再指向任何值了,用Rust的概念就是变量a
目前处于未初始化状态。如果对一个未初始化的变量执行绑定操作,例如let c = a;
,那么Rust就会提示一个错误,告诉你变量a
所拥有的所有权已经移交。
!!! note ""
但是一个重新变成未初始化状态的变量,是可以不使用let
关键字,直接绑定新的值的。绑定操作实际上是操作符=
完成的,let
关键字所作的事情是声明一个未初始化的变量。
看,这样是不是就已经解决了悬垂指针的问题?那么随之而来的问题就是,在程序中,一块内存区域的操作,远远不止所有权移交这么简单。所以以所有权为基础,Rust发展出了一整套的内存操作概念和方法。
所有权的转移
使用操作符=
来转移内存区域的所有权是Rust中最简单的所有权移交的方法,但是在实际编程过程中,还有许多并不通过操作符=
来移交所有权的操作。
从函数返回一个值
在Rust中,从函数中返回的值通常不是这个值的引用,而是这个值本身,所以从一个函数返回值的时候,函数所持有的值的所有权会随着函数的返回而移交。例如以下示例。
let mut employees = Vec::new();
向函数传递一个值
向一个函数传递值的时候,实际上就是把一个值的所有权转移给了函数。例如有以下结构体和函数。
struct Person {
name: String,
age: i32
}
employees.push(Person { name: "Peterson".to_stirng(), age: 25 });
在这个示例中,结构体字段name
被字符串的.to_string()
方法初始化,并取得了字符串的所有权,但是这个结构体的新实例被传递给了向量的.push()
方法,那么这个方法的运行结果就是结构体的新实例被加入到了向量的末尾,向量取得了结构体新实例的所有权,也同时间接的取得了结构体中name
字段的所有权。
在控制流中转移所有权
在继续讨论更加复杂的所有权转移的情况之前,需要首先牢记Rust中的一个最基本的原则。
!!! caution "所有权转移的基本原则" 如果一个变量的值已经被转移了,而且转移以后这个变量始终没有获得新值,那么这个变量就会被认为是未初始化的。
对于一个控制流语句来说,一个变量必须在分支语句执行前后都始终有值,才能够在所有分支中使用。例如以下示例。
let mut x = vec![10, 20];
if condition {
f(x); // 在这里转移变量x的所有权是没有问题的。
} else {
g(x); // 在这里转移变量x 的所有权也是没有问题的。
}
h(x); // 变量x的所有权已经在之前的分支中被转移了,所以这里h()将无法获得变量x的所有权。
在循环体内转移所有权
在循环体内进行的所有权转移,比分支语句中要复杂一些,如果一个变量在进入下一次循环的时候是未初始化状态,那么这个循环体也是无法通过编译的。例如以下两个示例。
let mut x = vec![10, 20];
while f(x) { // 这里函数f()取得了变量x中值的所有权,变量x在这里变为了未初始化状态
g(x); // 这里再试图转移变量x中值的所有权会失败。
}
上面这个示例还是比较容易纠错的,毕竟在进入循环体之前,变量x中值的所有权就已经被转移了。但是下面这个示例就不太一样了。
let mut x = vec![10, 20];
while condition {
g(x); // 变量x的值在执行第一次循环的时候就被转移了,所以在循环执行第二次的时候,变量x就已经是未初始化状态了。
}
所以要解决这个问题的办法是,在每次循环结束之前,给所有变成未初始化状态的变量一个新值。
使用索引转移所有权
对于一个向量来说,是可以直接使用索引来转移其中元素的所有权的,例如以下示例。
let mut x = Vec::new();
for i in 10..20 {
x.push(i.to_string());
}
let element3rd = x[2];
let element4th = x[3];
在这个示例中,element3rd
和element4th
两个变量分别通过索引取得了向量x
中第三个和第四个元素的所有权,那么这时向量x
中的第三个元素和第四个元素就已经变成未初始化状态了。但是在实际程序中,这种变化需要向量记录更多的元信息来处理其中元素的未初始化状态。上面这个示例其实是无法通过Rust编译的,Rust编译器会给出一个使用引用来代替转移的方法,然而在一般的编程需求中,我们也的确并不需要转移向量中的值,所以大部分情况下只需要按照Rust的建议修改即可。
Copy
类型
其实在Rust中并不是所有类型的值都是采用转移所有权的方式传递的,一般来说只有那些占用内存较多而且复制比较耗时的类型会采用转移所有权的方式进行传递。对于这些类型来说,转移所有权可以让赋值的代价更低。但是一些比较基础的简单类型,例如整数或者字符,在赋值的时候往往会采用复制的方法。对于这些使用复制代替转移的类型,Rust称它们为Copy
类型。
要判断一个类型是否是Copy
类型并不复杂,如果一个类型在其值被清除以后需要特殊处理的,那么这个类型就不可能是Copy
类型。常见的Copy
类型主要有所有的机器整数和浮点类型、字符和布尔类型,还有就是由Copy
类型组成的元组和大小固定的数组。从原理上来说,Copy
类型需要能够完成逐位复制,并且逐位复制是需要有意义的。例如对于持有文件句柄的File
类型,即便其可以完成逐位复制,也没有什么意义,因为多个线程同时操作一个文件可能会发生不可预知的潜在问题。常用的Box<T>
包装类型也不是Copy
类型,因为包装类型都拥有分配在堆上的缓冲区。这些非Copy
类型如果采用逐位复制,那么会使编译器无法分辨哪个值需要对被引用的原始资源负责。
!!! note ""
如果一个Copy
类型参与了用户自定义类型struct
和enum
等的组成,那么其也会变成非Copy
类型。但是如果用户自定义的struct
中的字段都是Copy
类型的话,可以在结构体定义上方标注#[derive(Copy, Clone)]
来将这个结构体标注为Copy
类型。
在Rust中,所有的转移都是浅复制,会导致源变量变成未初始化状态,但是Copy
类型的复制则会让源变量保持初始化。
共享所有权
虽然在Rust中,值的所有者通常只能有一个,但是在大多数情况下,程序中很难找到每个值只有一个所有者时所需要的生命周期,也就是说,往往一个值可能在它的所有者已经超出声明周期以后还可能是被需要的。这种情况下,大部分的编程语言都是采用基于计数的指针类型来确定一个值的真正生命周期的。
要在程序中使用引用计数技术,就必须使用Rust提供的std::rc::Rc
和std::rc::Arc
类型。这两个类型的区别是Arc
是原子引用计数类型,可以在线程之间共享指针,但其开销要远较Rc
类型高,所以在无需跨线程共享指针的时候,只需要使用Rc
类型即可。
例如以下示例中,三个变量将都指向同一块内存区域,而这块内存区域也将在最后一个Rc
变量被清除以后,被Rust清除。
use std::rc::Rc;
let s: Rc<String> = Rc::new("Hello".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();
在使用Rc
访问一个类型的内存区域的时候,是可以直接使用这个类型所提供的大多数方法的,但所有可能会修改这个内存区域中的值的方法都是不可用的。例如在上面示例中,s.push_str("world")
的调用将会被Rust编译器拒绝。
所以Rust对于内存和线程安全的保证是基于不存在既共享又可以被修改的值的。
引用
在其他语言如C/C++的编程中,引用是一个使用频率非常高的词汇。大部分的指针类型都是所有型指针,这就意味着当这个指针被清除的时候,其指向的资源也会被清除掉。但是有很多情况下,程序中的变量只是临时使用一下资源,并不需要取得其所有权,这种情况下就需要使用引用来解决了。
引用也是一种指针,只是这种指针对其所指向资源的生命周期没有影响,而且在事实上,引用的生命周期不能超过其指向资源的生命周期。引用的这个生命周期限制在Rust程序中被要求在代码中明确体现。为了显著体现引用的这一特点,Rust把创建某个值的引用的操作称为借用,意为“借来使用的东西,必须要尽快归还”。
例如根据以上对于所有权传递的描述,以下示例的编译将会报错。
fn show(arr: Vec<String>) {
for s in arr {
println!(" {}", s);
}
}
fn main() {
let mut strArray: Vec<String> = vec!["item1", "item2", "item3"];
show(atrArray);
asset_eq!(strArray[0], "item1");
}
在执行完show()
函数以后,Rust编译器会提示strArray
已经是未初始化状态了。但是show()
函数中并没有对传入的向量做任何的改变,但是Rust却把传入的向量在函数执行结束的时候整个销毁了。所以在这种情况下,就应该使用引用来借用资源的访问权。
用引用修改上面示例,就变成以下这样,也就能够通过Rust编译了。
fn show(arr: &Vec<String>) {
for s in arr {
println!(" {}", s);
}
}
fn main() {
let mut strArray: Vec<String> = vec!["item1", "item2", "item3"];
show(&atrArray);
asset_eq!(strArray[0], "item1");
}
!!! note ""
注意一下示例中引用的生命周期,show()
函数中创建的引用,其生命周期是小于被引用的变量strArray
的。这一点具体见后文中关于生命周期的叙述。
Rust中的引用与C/C++中的引用不一样,是永远不能为空的,所以Rust中不存在像是C/C++中nullptr
之类的值存在。而且Rust不会把整数转换成引用,也没有一个默认的初始值存在。如果在Rust中需要表示一个可能是引用,也可能什么都不是的值,那么可以使用Option<&T>
类型,其中None
表示为空指针,Some(r)
表示为非零指向,这种使用Option<&T>
类型表示空指针的方法,比C/C++中更加安全,因为必须在引用被使用之前检查其是否为None
。
与C/C++另一个不同的地方在于,Rust允许创建对任何类型表达式值的引用,即借用任何类型表法师的值。在这种情况下,Rust将创建一个匿名变量来保存表达式的值,然后生成一个指向这个值的引用,这个匿名变量的生命周期取决于引用的生命周期。如果这个引用被赋予一个变量,那么这个匿名变量将拥有与被赋值的变量一样长的生命周期,否则这个匿名变量将仅存活至闭合语句末尾。
一写多读原则
不可变的变量是Rust给人的第一印象,这实际上是Rust对变量能够对指向的值完成什么操作的一个显式描述。所以在默认情况下定义的变量都是指向内容不可变的变量,如果需要能够改变所指向的值,这个变量就必须使用mut
关键字进行标记,这也是let mut x
变量定义的由来。
引用与变量一样,也有两种类型:共享引用和可修改引用。直接使用&
创建的引用都是共享引用,例如&x
将创建一个对变量x
所指向内存区域的引用,这个引用是不可变的,也就是只读的。共享型引用在传递的时候都是采用复制传递的,而不是转移所有权的传递,也就是说共享型引用都是Copy
类型的。
可修改引用使用&mut
创建,例如&mut x
将创建一个可以对变量x
指向的内存区域进行修改的引用。相比共享型引用,可修改引用在同一时刻只能存在一个,并且可修改引用不是Copy
类型的,在传递的时候将会转移所有权。
这两种引用的主要区别就是共享引用和可修改引用在编译的时候会执行多读和单写的检查。多读检查会检查值的共享引用,只要一个值存在共享引用,那么不仅值的可修改引用不能修改值,值的所有者也同样不能修改值,但是这个值的多个共享引用可以同时对值进行读取。相应的,如果值存在的可修改引用,那么这个可修改引用就会拥有值的排他读写权,所以在可修改引用存续的期间,值的所有者也同样不能对值进行修改。
将共享引用与可修改引用分开处理,是Rust保证内存安全的重要前提。所以在程序中,如果一个函数不需要修改传入的参数,那么一般应该选择使用共享引用来传递。
所以与其他的大部分语言一样,Rust更加强调在调用函数时给函数传参的形式差异。如果以转移所有权的方式将值传递给函数,那么这个函数的调用方式就是传值调用,如果调用函数的时候是采用传递引用的方式,那么这个函数的调用方式就是传值调用,或者叫传引用调用。
解引用
跟C/C++中的指针需要解指向一样,在Rust中使用引用也是需要解引用的。引用通过&
操作符显式创建,在解引用的时候也必须显式使用*
操作符,但是在对引用使用.
操作符的时候,.
操作符会在必要的时候对其左操作数进行隐式解引用。.
操作符除了可以对引用进行隐式解引用以外,还可以在调用被引用的结构体方法的时候,自动隐式借用其左操作数的引用。
例如以下这些示例。
let mut x = 10;
let y = &x; // 借用变量x,创建一个共享引用。
assert!(*y == 10); // 显式对引用y进行解引用。
let m = &mut x; // 借用变量x,创建一个可修改引用。
*m += 32; // 显式对引用m进行解引用以更新变量x的值。
assert!(*m == 64); // 读取可修改引用m的新值同样也需要解引用。
// 定义一个结构体
struct Person {
name: &'static str,
age: i32
};
let employee = Person { name: "Peter", age: 30 };
let employee_ref = &employee; // 借用这个结构体实例。
assert_eq!(employee_ref.name, "Peter"); // 这里使用结构体中name字段的时候,会自动进行解引用。
// 自动解引用操作相当于下面这种显式解引用的写法,但是这种写法显然比较不美观。
assert_eq!((*employee_ref).name, "Peter");
// 定义一个向量,vec!宏会返回一个借用向量变量的引用。
let mut v = vec![1, 2, 5, 6];
v.sort(); // sort()函数会自动借用对可修改引用v的引用。
// 上面的自动借用相当于以下写法,但是同样更加不美观。
(&mut v).sort();
Rust允许对借用其他引用,而且在部分操作中,Rust可以自行推断引用的类型,所以对于嵌套的引用类型,在创建引用的时候可以省略,而且不管创建了多少层引用,.
操作符都可以获取到被引用的最终值。与.
操作符特性相似的是比较操作符,只要左右两个操作数的类型相同,比较操作符也可以自动对引用以及嵌套的引用进行解引用,并比较被引用的最终值。
生命期
生命期是Rust中控制引用的关键规则,通过使用生命期规则,Rust可以以一个非常明显的方式避免不安全的指针使用。例如下面这个借用局部变量的示例。
{
let ref;
{
let x = 1;
r= &x; // 借用x
} // 变量x在这里将被销毁
assert_eq!(*r, 1); // 引用r所借用的变量x的值在这里已经不存在了,所以Rust将拒绝这条语句通过编译。
}
在这个示例中引用r
在变量x
结束其生命期以后就变成了一个悬垂指针,这在Rust中是不被允许的。Rust在检查这段代码的时候,会给其中的每一个引用都附加一个生命期,这个生命期是表示在程序中可以安全使用引用的一个范围,每个引用的生命期取决于其类型,而并不是一个运行时中存在的内容。对于上面这个示例来说,引用&x
的生命期仅存在于内部语句块中,但是借用了变量x
的引用r
的生命期却在万步的语句块中,这就使得引用r
的生命期长于了引用&x
。
所以Rust中就约束了两条规定:变量的生命期必须包含或者覆盖从它那里借用的引用的生命期,而且保存在引用变量中的引用,其类型必须保证它在被借用变量的整个生命期中都有效。
根据这两条规定,上面这个失败的示例必须修改成以下这样,才能够保证通过Rust编译。
{
let x = 1; // 变量x的生命期自此处开始
{
let r = &x; // 引用r的生命期自此处开始
assert_eq!(*r, 1);
} // 引用r的生命期自此处结束
} // 变量x的生命期自此处结束
这样一来,引用r
的生命期就被包含在其借用的变量x
的生命期中了,也就同时满足了上面的两条规定。
生命期参数
当把一个引用传给函数的时候,情况会变得更加的复杂。例如在以下示例中出现的在其他寓言中常用的全局变量的例子,在Rust中就无法通过编译。
static mut X: i32;
fn f(p: &i32) {
X = p;
}
在Rust中静态变量,即变量X
,与其他语言中的全局变量是等价的,其生命期是从程序启动时开始直到程序终止时结束。所以在上面这个示例中,函数参数p
的生命期是未知的,或者说是任意长度的,只能满足覆盖函数f
的调用,并不能比肩静态变量X
的生命期长度,所以在编译的时候Rust会提示参数p
所借用的生命期长度不够长。
事实上,常用的Rust函数定义是省略了一些内容的,如果将其补全,将会是以下这个样子。
fn f<'a>(p: &'a i32) {
// ...
}
在这里'a
表示函数f
的生命期参数,其中<'a>
表示“对于任意生命期”的意思。所以上面这个这个定义表示函数f
接受一个具有任意给定生命期'a
的i32
类型引用。在这种定义下,Rust会要求传入参数的生命期尽可能的短,只要能够覆盖函数f
的调用即可,所以也就会出现前面无法通过编译的错误。
根据这条原则,可以让限制函数f
只能接受'static
生命期的引用来通过编译。所以上面示例可以修改成以下这样。
fn f(p: &'static i32) {
unsafe {
X = p;
}
}
!!! info "" 这就是Rust的另一个好处,当看到函数的定义或者签名的时候,就能够知道这个函数对参数生命期的要求,也就大概知道这个函数能够对所传入的参数做哪些操作。这也是Rust保证函数安全调用的一个前提。
!!! caution ""
需要注意的是,在上面这个最后可以通过编译的示例中,如果把一个非'static
生命期的引用传给函数f
,那么Rust同样会拒绝编译,因为任意生命期的引用不能满足要求'static
生命期参数的函数f
的胃口。
返回一个引用
在所有的编程语言中,都存在在函数中直接返回对某一数据结构中某一部分内容的引用。比如在以下示例中,函数返回了一个切片中最大值的引用。
fn largest(arr: &[i32]) -> &i32 {
let mut s = &v[0];
for r in &v[1..] {
if *r > *s {
s = r;
}
}
s
}
这个示例主语表达了在Rust中,如果一个函数接受一个引用作为参数并返回一个引用的时候,Rust会假设两个引用会具有相同的生命期。这个函数的完整写法是下面这样的。
fn largest<'a>(arr: &'a [i32]) -> &'a i32 {
// ...
}
从函数的完整签名可以看出来,这个函数的返回值至少要跟函数参数的生命期一样长。如果在使用这个函数的场景中,出现了不同时满足函数参数与返回值生命期的情况,Rust就会拒绝编译。
!!! info "" 如果一个函数没有返回任何引用,那么就无需写出函数的生命期参数。
在结构体中使用引用
Rust对于引用的约束不会因为引用被用在了结构体中而失效,当引用类型出现在结构体或者其他类型的定义中时,Rust要求必须写出其生命期。例如以下这样的定义。
struct GS {
r: &'static i32
}
但是这样声明使用静态生命期的限制太大了,这会导致这个结构体在很多场景下都无法使用,所以还是需要指定一个更小更灵活的生命期。把上面这个示例改写一下就是下面的样子。
struct GS<'a> {
r: &'a i32
}
现在结构体类型GS
就有了一个指定的生命期,每一个GS
类型的值也都会有一个新的生命期'a
,任何在结构体中字段r
中保存的生命期都必须包含'a
,而且'a
也必须比保存GS
类型值的任何值都要长寿才可以。
当把带有生命期参数的结构体用在其他类型定义中时,也同样必须要指明生命期。例如以下示例。
struct TS {
g: GS
}
这个示例中不包含任何生命期参数,所以在编译过程中Rust将会报错,因为Rust无法确定结构体TS
的生命期与结构体GS
的生命期之间的关系。所以结构体TS
的定义并须也要加入生命期参数才行。
struct TS<'a> {
g: GS<'a>
}
!!! caution ""
这里需要注意的是,结构体TS
的生命期可以与结构体GS
的生命期不同,例如在结构体TS
中,可以声明使用'static
生命期。
!!! info ""
一个结构体中的多个引用可以拥有独立的生命期参数,这只需要在<>
中使用逗号分隔列出即可。但是此时Rust所需要确定的事情是,结构体的值是否仅存在于所有已经声明的生命期交集中。
如果一个函数是结构体中的一个方法,而且它接受了一个引用形式的self
参数,那么Rust就会假定self
的生命期就是返回值的生命期,因为Rust假定无论你借用了什么,都是从self
中借用的。这是Rust中生命期推断的一个特例。
闭包中的借用
闭包实际上就是日常所说的匿名函数,只是这种匿名函数具有许多行内表达式的特点,而且必报还具有许多普通函数所不具有的特点。作为闭包,捕获其上下文中的变量是一项最基本的功能。
以以下Rust闭包示例为例。
fn sort_by_scores(persons: &mut Vec<Person>, score: Score) {
persons.sort_by_key(|person| -person.get_score(score));
}
Rust在创建闭包的时候会自动借用对上下文中score
的引用,但是Rust不会让这个借用的存活期超过score
,所以闭包只在被调用的期间使用,是没有什么问题的。
但是多线程中的闭包调用就不太一样了。以下是上面这个示例的多线程版本。
use std::thread;
fn start_sort_thread(mut persons: Vec<Person>, score: Score) -> thread::JoinHandler<Vec<Person>> {
let key_fn = |person| -> i64 { -person.get_score(score) };
thread::spawn(|| {
persons.sort_by_score(key_fn);
persons
})
}
在这个示例中看起来闭包的时候与之前的示例几乎差不多,但是在闭包被实际调用的时候,score
可能已经超出其生命期了。而且使用thread::spawn
创建的线程也不能保证自己能够在persons
和score
被销毁前完成任务。此时,Rust不能保证闭包的安全使用。
为了能够让这个示例正常工作,就需要把persons
和score
“偷”到闭包里。要完成这个操作,需要使用Rust提供的一个关键字move
进行标识。move
关键字会提示Rust,闭包对于上下文中的引用不是借用而是强行偷走,此时闭包将会获得其偷走的引用的所有权。闭包的这个转移的特性也同样遵守Rust中关于所有权转移和借用的规则。
虽然被强行转移的引用并不会在闭包结束后被转移回来,但是如果在闭包之后还是需要使用被转移的引用内容,可以事先把会被转移的内容保存一个副本,然后将闭包限制在新副本中。