所有权
所有权(系统)是 Rust 最为与众不同的特性,对语言的其他部分有着深刻含义。它让 Rust 无需垃圾回收(garbage collector)即可保障内存安全,因此理解 Rust 中所有权如何工作是十分重要的。
什么是所有权规则?
Rust
中所有权的规则是:
1.Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
意味着所有的值都有对应的所有者,并且只有一个所有者。
rustlet s = String::from("rust");// rust字符串的所有者是变量s
2.值在任一时刻有且只有一个所有者。
也就是说如果执行了变量所有权改变的话,例如执行了:
s2 = s1
的话,为防止二次释放,会对数据的所有权进行转移。rustlet s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // 错误! // 发生了所有权转移,s1失去了所有权,不可能出现对同一个值的两个所有者s1和s2
3.当值的所有者离开作用域,这个值将被丢弃;
意味着当该变量跳出有效作用域的话,该变量将自动释放,也就是不可再使用。
rust{ // 在声明以前,变量 s 无效 let s = "rust"; // 这里是变量 s 的可用范围 } // 变量范围已经结束,变量 s 无效,不可再用
编译器会对这些规则进行检查,如果违反了任何规则,那么程序都不能编译通过。此外,在运行时,所有权系统的任何功能都不会减缓程序。
变量作用域
作用域是一个变量在程序中有效的范围, 假如有这样一个变量:
rust
let s = "hello"
变量 s
绑定到了一个字符串字面值,该字符串字面值是硬编码到程序代码中的。s
变量从声明的点开始直到当前作用域的结束都是有效的:
rust
{ // s 在这里无效,它尚未声明
let s = "hello"; // 从此处起,s 是有效的
// 使用 s
} // 此作用域已结束,s不再有效
简而言之,s
从创建伊始就开始有效,然后有效期持续到它离开作用域为止,可以看出,就作用域来说,Rust 语言跟其他编程语言没有区别。
变量与数据交互的方式
所有权与Move
先来看一段代码:
rust
let x = 5;
let y = x;
代码背后的逻辑很简单, 将 5
绑定到变量 x
;接着拷贝 x
的值赋给 y
,最终 x
和 y
都等于 5
,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。
可能有同学会有疑问:这种拷贝不消耗性能吗?实际上,这种栈上的数据足够简单,而且拷贝非常非常快,只需要复制一个整数大小(i32
,4 个字节)的内存即可,因此在这种情况下,拷贝的速度远比在堆上创建内存来得快的多。实际上,我们讲到的 Rust 基本类型都是通过自动拷贝的方式来赋值的,就像上面代码一样。
然后再来看一段代码:
rust
let s1 = String::from("hello");
let s2 = s1;
之前也提到了,对于基本类型,Rust 会自动拷贝,但是 String
不是基本类型,而且是存储在堆上的,因此不能自动拷贝。
实际上, String
类型是一个复杂类型,由存储在栈中的堆指针、字符串长度、字符串容量共同组成,其中堆指针是最重要的,它指向了真实存储字符串内容的堆内存,容量是堆内存分配空间的大小,长度是目前已经使用的大小。
总之 String
类型指向了一个堆上的空间,这里存储着它的真实数据, 下面对上面代码中的 let s2 = s1
分成两种情况讨论:
- 拷贝
String
和存储在堆上的字节数组 如果该语句是拷贝所有数据(深拷贝),那么无论是String
本身还是底层的堆上数据,都会被全部拷贝,这对于性能而言会造成非常大的影响 - 只拷贝
String
本身 这样的拷贝非常快,因为在 64 位机器上就拷贝了8字节的指针
、8字节的长度
、8字节的容量
,总计 24 字节,但是带来了新的问题,还记得我们之前提到的所有权规则吧?其中有一条就是:一个值只允许有一个所有者,而现在这个值(堆上的真实字符串数据)有了两个所有者:s1
和s2
。
好吧,就假定一个值可以拥有两个所有者,会发生什么呢?
当变量离开作用域后,Rust 会自动调用 drop
函数并清理变量的堆内存。不过由于两个 String
变量指向了同一位置。这就有了一个问题:当 s1
和 s2
离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free) 的错误,也是之前提到过的内存安全性 BUG 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。
因此,Rust 这样解决问题:当 s1
赋予 s2
后,Rust 认为 s1
不再有效,因此也无需在 s1
离开作用域后 drop
任何东西,这就是把所有权从 s1
转移给了 s2
,s1
在被赋予 s2
后就马上失效了。
再来看看,在所有权转移后再来使用旧的所有者,会发生什么:
rust
// 错误示例
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
由于 Rust 禁止你使用无效的引用,你会看到以下的错误:
error[E0382]: use of moved value: `s1`
--> src/main.rs:5:28
|
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, world!", s1);
| ^^ value used here after move
|
= note: move occurs because `s1` has type `std::string::String`, which does
not implement the `Copy` trait
如果你在其他语言中听说过术语 浅拷贝(shallow copy) 和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据听起来就像浅拷贝,但是又因为 Rust 同时使第一个变量 s1
无效了,因此这个操作被称为 移动(move),而不是浅拷贝。上面的例子可以解读为 s1
被移动到了 s2
中。那么具体发生了什么,用一张图简单说明:
这样就解决了我们之前的问题,s1
不再指向任何数据,只有 s2
是有效的,当 s2
离开作用域,它就会释放内存。 相信此刻,你应该明白了,为什么 Rust 称呼 let a = b
为变量绑定了吧?
再来看一段代码:
rust
fn main() {
let x: &str = "hello, world";
let y = x;
println!("{},{}",x,y);
}
这段代码和之前的 String
有一个本质上的区别:在 String
的例子中 s1
持有了通过String::from("hello")
创建的值的所有权,而这个例子中,x
只是引用了存储在二进制中的字符串 "hello, world"
,并没有持有所有权。
因此 let y = x
中,仅仅是对该引用进行了拷贝,此时 y
和 x
都引用了同一个字符串。
所有权与Copy
在上面的例子中,我们如果执行的话,会提示数据的所有权已经发生了移动,发生编译错误。如下所示:
rust
// 错误示例
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{} {}", s1, s2);
}
如果把s1
换成整型的话,会是什么样的?如下所示:
rust
fn main() {
let s1: i8 = 5;
let s2 = s1;
println!("{} {}", s1, s2);
}
编译是通过的。原因在于,本例子中,整型这样的在编译时已知大小的类型被整个存储在栈上,执行let s2 = s1;
的话会发生拷贝,这时候,s1
和 s2
是完全独立的数据。换句话说,这里没有深浅拷贝的区别。
这里稍微要超前讲一点关于 trait 的知识。Rust 有一个叫做 Copy
trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上(Trait章节将会详细讲解 trait)。如果一个类型实现了 Copy
trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。
这种不拥有其他资源并且可以按位复制的类型称为复制类型。它们由Rust自己实现了 Copy Trait。目前所有基本类型,如整数、浮点数和字符都是Copy
类型。默认情况下,struct/enum
不是Copy
类型,但你可以派生 Copy
trait,更多详细介绍可以学习Trait章节。
那么哪些类型实现了 Copy
trait 呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy
,任何不需要分配内存或某种形式资源的类型都可以实现 Copy
。如下是一些编译器自动实现的 Copy
的类型:
- 所有整数类型,比如
u32
。 - 布尔类型,
bool
,它的值是true
和false
。 - 所有浮点数类型,比如
f64
。 - 字符类型,
char
。 - 元组,当且仅当其包含的类型也都实现
Copy
的时候。比如,(i32, i32)
实现了Copy
,但(i32, String)
就没有。
如果回头看第一个例子的报错信息,会提示 s1
没有实现 Copy
trait,也就是String类型没有实现 Copy
trait:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:23
|
3 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
4 | let s2 = s1;
| -- value moved here
5 | println!("{} {}", s1, s2);
| ^^ value borrowed here after move
注:Rust 不允许自身或其任何部分实现了 Drop
trait 的类型使用 Copy
trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy
注解,将会出现一个编译时错误。要学习如何为你的类型添加 Copy
注解以实现该 trait,请阅读附录 C 中的 “可派生的 trait”。
有关Move和Copy的深入理解请参考Rust中级知识点-理解Move和Copy。
所有权与Clone
在Rust
中,每一个值都有对应的所有权变量。一旦发生下方的代码就意味着所有权发生了转移:
rust
let s1 = String::from("hello"); // 数据存放在堆上
let s2 = s1; // s1将字符串的所有权转移到了s2。这个时候s1失去所有权被清除。
利用图来解释上面的代码的执行过程:左边的数据存放在栈上,内容包括数据存放的地址,数据的长度、容量;右边的数据存放在堆上。
将s1
的值赋值到s2
,栈上的数据被复制了,并没有复制堆上的数据。此外,为了避免在释放的时候造成二次释放,复制以后Rust
认为s1
已经失去了数据的所有权,会被释放掉。在Rust
中编译器会自己调用drop
函数。所以转移之后,s1
无效,s2
有效。
复制栈上的数据指针就是一个浅拷贝,Rust
永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制可以被认为对运行时性能影响较小。
如果在实际的使用中,确实需要对数据进行复制操作,Rust也提供了clone函数。
rust
let s1 = String::from("hello"); // 数据存放在堆上let s2 = s1.clone(); // 这个时候s1和s2都是有效的。
类似于下图的方式:
这里必须再说明一下,我们在下面的代码中会发现是可以运行的:
rust
let a = 1;let b = a;println!("a = {}, b = {}", a, b);
这似乎与我们上面所讲的矛盾?其实并不是的,Rust
对于像整型这样的在编译时已知大小的类型被整个存储在栈上,Rust
对于类似于整型这样的类型提供了Copy trait
。如果一个类型实现了 Copy trait
,那么一个旧的变量在将其赋值给其他变量后仍然可用。
那么这些类型都有哪些?
- 所有整数类型,比如
u32
。 - 布尔类型,
bool
,它的值是true
和false
。 - 所有浮点数类型,比如
f64
。 - 字符类型,
char
。 - 元组,当且仅当其包含的类型也都实现
Copy
的时候。比如,(i32, i32)
实现了Copy
,但(i32, String)
就没有。
Rust 不允许自身或其任何部分实现了 Drop
trait 的类型使用 Copy trait
。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy
注解,将会出现一个编译时错误。
要学习如何为你的类型添加 Copy
注解以实现该 trait
,请阅读的 “可派生的 trait”。
所有权与函数
在函数中,将某一个变量作为参数输入后,该变量同样失去所有权。
rust
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的值移动到函数里,
// s 没有Copy属性,所以之后失效
let x = 5; // x 进入作用域
makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,
// 所以在后面可继续使用 x
} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走。
fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。
// 占用的内存被释放
fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer);
} // 这里,some_integer 移出作用域。
所有权与返回值
返回值也可以返回所有权。
rust
fn main() {
let s1 = gives_ownership(); // gives_ownership 将返回值转移给 s1
let s2 = String::from("hello"); // s2 进入作用域
let s3 = takes_and_gives_back(s2); // s2 被移动到takes_and_gives_back中,s2失效,它也将返回值移给 s3
} // 这里,s1, s3 移出作用域并被丢弃。
fn gives_ownership() -> String { // gives_ownership 会将返回值所有权移动给调用它的函数
let some_string = String::from("yours");
some_string
}
fn takes_and_gives_back(a_string: String) -> String {
a_string // 返回 a_string 并移出给调用的函数
}
所有权与借用
在实际的场景中,有这样的情况:我们利用函数对输入参数进行某种运算后得到一个结果,并且原数据我们还将继续使用。这个时候我们用到了借用与引用。
借用不拥有数据的所有权,只是借过来使用一下,采用的方式是引用。
rust
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize { // 采用引用的方式对数据进行借用
s.len()
}
通过引用来借用的行为类似于下方的示意图:
因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。同样,直接借来的值也并不能修改。如下会报错:
rust
fn main() {
let s = String::from("hello");
change(&s);
}
fn change(some_string: &String) {
some_string.push_str(", world"); // 试图修改借来的值
}
如果想要修改借来的值,只需要将引用改变为可变引用,这样是可以修改借来的值,相当于由所有权的拥有者赋权给了借用者修改值的权利:
rust
fn main() {
let mut s = String::from("hello"); // 首先声明变量是可变的
change(&mut s); // 然后,这里指明是可变引用
}
fn change(some_string: &mut String) { // 参数的声明也要指出是可变的
some_string.push_str(", world");
}
可变借用的限定
Rust
规定,在同一个时间只能有数据的一个可变引用。这个很容易理解,在多线程的情况下,当同时存在多个数据的可变引用的话,那么如果同时修改的话,会造成数据竞争;单线程情况下不会发生数据竞争。
数据竞争,它可由这三个行为造成:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针被用来写入数据。
- 没有同步数据访问的机制。
此外,Rust
也不能在拥有不可变引用的同时拥有可变引用。这会造成数据在使用的过程中被其他借用者修改的情况。但是可以在不可变引用的生命周期结束后建立可变引用。这不算同时拥有。
rust
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{} and {}", r1, r2); // 此位置之后 r1 和 r2 不再使用
let r3 = &mut s; // 没问题
println!("{}", r3);
}