所有权
什么是所有权?
Rust的核心功能之一是所有权。虽然该功能很容易解释,但它对语言的其他部分有着是深刻的影响。
所有程序都必须管理其运行时使用的计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时有规律的寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和使用内存。Rust则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。
因为所有权对很多程序员来说都是一个新概念,需要一些时间来适应。
栈(Stack)与堆(Heap)
在很多语言中,并不需要考虑到栈与堆。不过像Rust这样的系统编程语言,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何做出这样的抉择。
栈和堆都是代码在运行时可供使用的内存,但是他们的结构不同,栈以当入值的顺序存储值,并以相反的顺序取出值。这也被称作后进先出。想象一下一叠盘子:当增加更多盘子时,把它们当在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或者拿走盘子。增加的数据叫做进栈,而移走数据叫做出栈。栈中的所有数据都必须占用已知且固定的大小。而编译时大小未知或大小可能变化的数据,要改为存储在堆上。堆是缺乏组织的:当想堆放入数据时,你要请求一定大小的的空间。内存分配器在堆的某处找到一块足够大的空位,把它标记已使用,并返回一个表示该地址位置的指针。这个过程称作在堆上分配内存,有时简称为“分配”。
入栈比在堆上分配内存要快,因为入栈时分配器无需为存储心数据而搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。
访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快。继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报玩所有菜后再移动到下一个桌子是最有效率的。从桌子A听一个菜,接着桌子B听一个菜,然后再桌子A,然后再桌子B这样的流程会更加缓慢。处于同样原因,处理器在处理的数据比较近的时候比比较远的时候能更好的工作。
跟踪哪部分代码正在使用堆上的数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据代码确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑吧栈和堆了,不过明白了所有权的主要目的就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。
所有权规则
首先,让我们看一下所有权的规则。当我们通过举例说明时,请谨记这些规则:
- Rust中的每一个值都有一个所有者。
- 值在任一时刻有且只有一个所有者。
- 当所有者离开作用域,这个值将被丢弃。
变量作用域
在所有权的第一个例子中,我们看一看变量的作用域。作用域是一个项在程序中有效的范围。假设有这样一个变量:
let s = "hello";
变量s绑定到了一个字符串字面值,这个字符串是硬编码谨程序代码中的。这个变量从声明的点开始到当前作用域结束都是有效的。
{ // s从这里无效,它尚未声明
let s = "hello"; // 从此处起,s是有效的
pringln!("{}", s)
} // 此作用域已结束,s不再有效
String类型
为了演示所有权规则,我们需要引入一个比较复杂的类型。前面介绍的类型都是已知大小的,可以存储在栈中,并且当离开作用域时被移出栈,如果代码的另一部分需要在不同的作用域中使用相同的值,可以快速简单的复制他们来创建一个新的实例。不过我们需要寻找一个存储在堆上的数据来探索Rust是如何知道该在何时清理数据的。
我们在刚刚提供的例子中已经见过字符串字面值,即被硬编码进程序里的字符串值。字符串字面值是很方便的,不过他们并不适合使用文本的每一种场景。原因之一就是他们是不可变的。另一个原因是并非所有字符串的值都能在编写代码时就知道。为此,Rust有第二个字符串类型,String
.这个类型管理被分配到堆上的数据,所以能够存储在编译时未知大小的文本。可以使用from
函数基于字符串字面值来创建String
,如下:
let s = String::from("hello");
这两个冒号::
是运算符,允许将特定的from
函数置于String
类型的命名空间下,而不需要使用类似string_from
这样的名字。
可以修改此类字符串:
fn main() {
let mut s = String::from("hello");
s.push_str(", world!");
println!("{}", s);
}
那么这里有什么区别呢?为什么String
可变而字面值却不行呢?区别在于两个类型对内存的处理上。
内存与分配
就字符串字面值来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,而且它的大小还可能随着程序运行而改变。
对于String
类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:
- 必须在运行时向内存分配器请求内存。
- 需要一个当我们处理完
String
时将内存返回给分配器的方法。
第一部分由我们完成:当调用String::from
时,它的实现请求其所需的内存。这在编程语言中是非常通用的。
然而,第二部分实现起来就各有区别了。在有垃圾回收的语言中,GC记录并清楚不再使用的内存,而我们并需要关心它。在大部分没有GC的语言中,识别出不再使用的内存并调用代码显式释放就是我们的责任了,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个bug.我们需要精确的为一个allocate
配对一个free
。
Rust采用了一个不同的策略:内存在拥有他的变量离开作用域后就被自动释放。下面是总用于例子的一个使用String
而不是字符串字面值的版本:
{
let s = String::from("hello"); // 从此处起,s是有效的
// 使用s
} // 此作用域已结束,s不再有效
这是一个将String
需要的内存返回给分配器的很自然的位置:当s
离开作用域的时候。当变量离开作用域,Rust为我们调用了一个特殊的函数。这个函数叫做drop
,在这个String
的作者可以释放内存代码。RUst在结尾处的}
处自动调用drop
.
变量与数据交互移动的方式(一):移动
在Rust中,多个变量可以采取不同的方式与同一数据进行交互。
let x = 5;
let y = x;
我们大致可以猜到这是干什么:“将5
绑定到x
;接着生成一个值x
的拷贝并绑定到y
”。现在有了两个变量,x
和y
都等于5
。这也是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个5
都被放入了栈中。
现在看看这个String
版本:
let s1 = String::from("hello");
let s2 = s1;
这看起来与上面的代码十分类似,所以我们可能会假设它们的运行方式也是类似的:也就是说,第二行可能会生成一个s1
的拷贝并绑定到s2
上。不过,事实上并不完全是这样。
String
由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。
长度表示String
的内容当前使用了多少字节的内存。容量是String
从分配器总共获取了多少字节的内存。长度和容量的区别是很重要的,不过在当前上下文中并不重要,所以现在可以忽略容量。
当我们将s1
赋值给s2
,String
的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如图所示。
之前我们提到过当变量离开作用域后,Rust自动调用drop
函数并清理变量的堆内存。这就会出现一个问题:当s1
和s2
离开作用域,它们都会尝试释放相同的内存。这是一个叫做二次释放的错误,也是之前提到过的内存安全性bug之一。两次释放相同的内存会导致内存污染,它可能会导致潜在的安全漏洞。
为了确保内存安全,在let s2 = s1;
之后,Rust认为s1
不再有效,因此Rust不需要在s1
离开作用域后清理任何东西。看看在s2
被创建之后尝试使用s1会发生什么?
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);
}
会得到一个类似如下的错误,因为Rust进制使用无效的引用。
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:11:20
|
9 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
10 | let s2 = s1;
| -- value moved here
11 | println!("{}", s1);
| ^^ value borrowed here after move
如果你在其他语言听说过术语深拷贝和浅拷贝,那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为Rust同时使第一个变量无效了,这个操作被成为移动,而不是叫做浅拷贝。
变量和数据交互的方式(二):克隆
如果我们确实需要深度复制String
中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做clone
的通用函数。
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
}
这段代码能正常运行,这里堆上的数据确实被复制了。
当出现clone
调用时,一些特定的代码被执行而且这些代码可能相当耗费资源。
只在栈上的数据:拷贝
这里还有一个没有提到的小窍门。这些代码使用了整型并且是有效的,以下是一个小例子:
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用clone
,不过x依然有效而且没有被移动到y中。
原因是像整型这样的在编译时已知大小的类型被整个存储到栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建y后使x无效。换句话说,这里没有深浅拷贝的区别,所以这里调用clone
并不会与通常的深拷贝有什么不同,我们可以不用管他。
Rust有一个叫做Copy
trit的特殊注解,可以用在类似整型这样的存储在栈上的类型上。如果一个类型实现了Copy
trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。
Rust不允许自身或其任何部分实现了Drop
tarit的类型使用Copy
tarit.如果我们对其值离开作用域时需要特殊处理的类型使用Copy
注解,将会出现一个编译时错误。
那么哪些类型实现了Copy
tarit呢?可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现Copy
,任何不需要分配内存或某种形式资源的类型都可以实现Copy
.如下是一些Copy
的类型:
- 所有的整数类型,比如
u32
- 布尔类型
- 所有浮点数类型
- 字符类型
- 元组,当切近当其包含的类型也都实现了
Copy
的时候。比如(i32, i32)实现了Copy
,但(i32, string)就没有。
所有权与函数
将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。示例如下:
fn main() {
let s = String::from("hello"); // s 进入作用域
taes_ownership(s); // s的值移动到函数里,从这里开始,s不可再使用
let x = 5; // x进入作用域
makes_copy(x); // x应该移动到函数里,但i32是Copy的,所以后面可以继续使用
} // x先移除作用域,然后是s。但s的值已经移动到了函数里
fn taes_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移除作用域
返回值与作用域
返回值也可以转移所有权。
fn main() {
let s1 = gives_ownership(); // gives_ownership将返回值转移给s1
println!("{}", s1);
let s2 = String::from("hello"); // s2进入作用域
let s3 = taes_and_gives_back(s2); // s2被移动到taes_and_gines_back中
println!("{}", s3)
} // 这里,s3移除作用域并被丢弃。s2也移除作用域,但已被移走,什么也不会发生.
fn gives_ownership() -> String {
let some_string = String::from("yours");
some_string
}
fn taes_and_gives_back(a_string: String) -> String {
a_string
}
变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动他。当持有堆中数据值的变量离开作用域时,其值将通过drop
被清理掉,除非数据被移动为另一个变量所有。
虽然这样是可以的,但是在每一个函数中都获取所有权并接着返回所有权有些罗嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?
幸运的是,Rust对此提供了一个不用获取所有权就可以使用值的功能,叫做“引用”