别的语言靠 GC,Rust 靠“所有权“:没有垃圾回收怎么保证内存安全?(一)什么是所有权
别的语言靠 GC,Rust 靠"所有权":没有垃圾回收怎么保证内存安全?
1.所有权
所有权是Rust的核心功能之一.
一些程序中有管理垃圾的回收机制用于管理其使用计算机内存的方式, 在程序运行的时候不断的寻找没有处于使用中的内存; 在另一些语言开发者必须手动分配和释放内存, 相信刚开始学习C语言的开发者有所体会; 然而Rust选择了截然不同的方式. 通过所有权系统管理内存. 编译器在编译的时候会根据一系列的规则进行检查. 所有权系统的任何功能都不会减慢程序.
所有权的规则:
- Rust中的每一个值都有一个被称为其所有者的变量
- 值在任何一个时刻都有且只有一个所有者
- 当所有者离开作用域, 这个值将会被丢弃
为了使这个概念更加容易, 本文将采用Rust的String类型来举例子,同时也会兼顾初学者解释String类型.
2.变量作用域
作用域是一个项在程序中的有效范围
以一个immutable的字符串类型为例:
let s = "Hello";
这个变量s绑定到了一个字符串字面量,这个字符串是硬编码进程序代码中的. 这个变量是从声明的那一刻其到当前的作用域结束都是有效的.
{ //作用域开始
//s在此处以及之前无效,在此以及之前没有声明过
let s = "Hello"; //此处开始有效, 到下一个}为止(作用域结束)都是有效的
} //作用域结束
所以实际上, Rust的作用域和其他编程语言是类似的.
在开始学习String类型之前,我们需要知道栈和堆的概念,因为String实际上是把指针, 长度, 类型基本信息存于栈上, 而指针指向我们真正存入的数据, 这一部分数据在堆上.
栈(Stack)与堆(Heap)
在很多语言中,你并不需要经常考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的抉择。我们会在本章的稍后部分描述所有权与栈和堆相关的内容,所以这里只是一个用来预热的简要解释。
栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出(last in, first out)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做 进栈(pushing onto the stack),而移出数据叫做 出栈(popping off the stack)。
栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针(pointer)。这个过程称作 在堆上分配内存(allocating on the heap),有时简称为 “分配”(allocating)。将数据推入栈中并不被认为是分配。因为指针的大小是已知并且固定的,你可以将指针存储在栈上,不过当需要实际数据时,必须访问指针。
想象一下去餐馆就座吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。
入栈比在堆上分配内存要快,因为(入栈时)分配器无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。
访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快(缓存)。继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有菜后再移动到下一个桌子是最有效率的。从桌子 A 听一个菜,接着桌子 B 听一个菜,然后再桌子 A,然后再桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间。
当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。
跟踪哪部分代码正在使用堆上的哪些数据,最大限度地减少堆上的重复数据量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的存在就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。
3.String类型
为了方便演示所有权的规则, 这里将先讲解String的一些基础知识, 这些方面也同样适用于标准库提供的或者自己创建的复杂数据类型.
上面的例子let s = "Hello"中, "Hello"就是字符串字面量, 即被硬编码进程序里的字符串值. 字符串字面量虽然很方便, 但是却不适用于所有场景. 其中一个重要的原因就是他们是不可以改变的, 另一个原因是并非所有的字符串在编写代码的时候就知道. 在获取用户输入的时候并存储就成为了一个问题. 所以Rust就引入了第二个字符串类型, String这个类型管理被分配到堆上的数据, 所以能存储在编译时未知大小的文本, 可以使用from函数基于字符串字面量来创建String.
以下是Rust中的两种字符串类型的比较:
| 类型 | 写法 | 存储位置 | 所有权 | 可变性 | 生命周期 |
|---|---|---|---|---|---|
| 字符串字面量 | &str | 程序二进制 | 借用(不可变引用) | 不可变 | static(全局有效) |
| String | String::new()/.to_string | 堆 | 拥有所有权 | 可变 | 动态, 离开作用域释放 |
如下:
let s = String::from("Hello")
双冒号运算符允许我们将特定的from函数置于String类型的命名空间下. 不需要string_from这样的名字, 这叫做关联函数.
可以修改此类字符串:
let mut s = String::from("Hello");
s.push_str(",world") //追加字符串
那么有什么区别呢? 为什么String类型可变, 但是字面量却不行呢? 区别在于这两个类型对内存的处理上.
4.内存与分配
就字符串的字面量而言, 我们在编译的时候就知道其内容, 所以直接硬编码进最终的可执行文件中就行了. 这使得字符串字面量快速且高效. 不过这些特性都只是得益于字符串字面量的不可变性. 不幸的是. 不能为每一个在编译时大小未知的文本而将一块文本放入二进制文件中, 并且它的大小还可能随程序的运行而改变.
对于String类型, 为了一个支持可变的, 可增长的文本需要在堆上面分配一块在编译时未知大小的文本来存放内容, 这意味着:
- 必须在运行的时候向内存分配器请求内存.
- 一个处理完String时将内存返回给分配器的方法.
第一部分:调用String::from时, 它实现请求所需的内存, 这在所有程序中是通用的
第二部分实现的时候区别出现了. 在有垃圾回收的语言中GC记录并清除不再使用的内存, 我们不用再关注. 如果是没有垃圾回收的语言, 识别出不在使用的内存, 并显示调用代码释放就是我们应该做到的事情了. 这也是历史上的一个难题, 如果忘记回收就会浪费内存,如果回收过早就会出现无效变量. 如果重复回收就成为了一个bug, 因此没有垃圾回收的语言需要我们精确地为一个allocate配对一个free.
与众不同的是Rust采取的策略是: 内存再拥有它的变量离开作用域后就自动释放.
{
let s = String::from("Hello")
}
当变量离开作用域的时候Rust为为我们调用一个特殊函数drop.在上面的例子中也就是在结尾}处自动调用drop.
但是, 在使用的时候场景非常复杂, 有以下场景:
数据交互的方式:
1. 变量与数据的交互方式(1): 移动
在Rust中, 多个变量能够以不同的方式与统一数据交互, 如:
let x = 5;
let y = x; //将变量x的整数值赋值给y 不过不要搞混了这里的代码是第三种场景(拷贝)不是 //移动
这个过程就是: "将5绑定到x, 接着生成一个值x拷贝并绑定到y", 现在的两个变量都等于5, 因为整数是已经知道固定大小的简单值, 所以这两个5都放入了栈中.
但是看以下的String版本:
let s1 = String::from("Hello");
let s2 = s1; //移动
这个代码看起来和上面的代码相似, 所以我们猜测第二行生成的是一个s1的拷贝并被s2绑定.
但是实际上却不是这样的.
String是由ptr指针, len长度 和 capacity容量组成.
当我们s1赋值给s2, String的数据被复制了. 这意味着我们从栈上面拷贝了它的指针,长度和容量
String 是"栈上的管理信息" + "堆上的实际数据"。变量绑定的是栈上的结构体,结构体里的指针指向堆数据。所有权转移时,栈结构体被复制,堆数据不动。
我们并没有复制指针指向堆上的数据.
重要的来了, 前面提到过变量离开作用域后, Rust会自动调用drop函数, 但是这里的两个数据指针指向了同一位置: 这时候就会产生二次释放的错误, 这就是之前提到的bug, 这个bug会污染内存, 从而导致安全漏洞
为了保证安全, Rust中就有了另一个值得注意的细节. 在let s2 = s1;后, Rust认为s1不再有效,这里完成了s1所指向的值的所有权的转交,所以不需要在s1离开作用域的时候清理任何东西.
2. 变量和数据的交互方式(2): 克隆
如果我们需要深度复制String中堆上的数据, 而不仅是栈上的数据, 就可以使用一个叫做clone的通用函数.
例子如下:
let s1 = String::from("Hello");
let s2 = s1.clone(); //克隆
这段代码能正常运行, 并且这里堆上的数据确实被复制了.
当出现clone调用的时候, 一些特定的代码会被执行可能相当消耗资源, 并且很容易察觉到发生的异常.
3. 只在栈上数据: 拷贝
像整型这样的在编译时候已经知道大小的类型会被存储在栈上, 所以拷贝是相当快速的. 意味着没有在创建y之后会使x无效. 所以这里没有什么深浅拷贝的区别.调用clone也就不会和通常的浅拷贝有什么不同.
但是, Rust中有一个叫做Copy trait的特殊标注, 可以用在类似整形这样存储在栈上的类型类型. 如果一个类型实现了这个trait, 那么这个类型的旧的变量在赋值给其他的变量之后依旧可以使用. 但是值得注意的是Rust不允许给有Drop trait的类型使用Copy trait, 如果使用将会出现编译时错误.
5.所有权与函数
在将值传递给函数的时候,在语义上与给变量赋值相似. 像函数传递值可能会发生移动或者复制, 像赋值语句一样.
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的值移动到函数里 ...
// ... 所以到这里不再有效
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 移出作用域。不会有特殊操作
6.返回值与作用域
返回值也可以转移所有权.
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 中,
// 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
// 所以什么也不会发生。s1 移出作用域并被丢弃
fn gives_ownership() -> String { // gives_ownership 将返回值移动给
// 调用它的函数
let some_string = String::from("yours"); // some_string 进入作用域
some_string // 返回 some_string 并移出给调用的函数
}
// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域
a_string // 返回 a_string 并移出给调用的函数
}
变量的所有权都遵循相同的模式, 变量在离开作用域的时候, 如果没有堆上的数据所有权转移的时候, 堆上的数据将和这个变量被一同清理掉, 但是如果我们在使用函数的时候, 每次想着把他传进去还要返回来就会很麻烦. 在下一节中将会讲解Rust提供的引用来解决这个烦恼.

浙公网安备 33010602011771号