Chapter4 Ownership
在 Rust 中, 非常独特的一个特性就是 Ownership. 正时 Ownership 机制保证了 Rust 在没有 GC 的情况下也可以做到内存安全. 因此学习 Rust 必须理解 Rust 的 Ownership 机制.
本文主要讲
- 什么时 Ownership?
- borrowing
- slices
- Rust 是如何在内存中存放数据的
栈和堆
Rust 程序在运行过程中的内存布局和典型的 C 程序类似, Rust 中数据如果想在栈上存放, 唯一的要求就是它的大小是在编译时可知的, 这和 C 类似.
如果要存放在编译时大小不确定或者是大小会变化的数据, 则需要在堆上分配内存, 内存分配器(memory allocator) 会在堆中找到一片可以存放该数据的区域, 将此区域标记为已用, 并返回该区域对应的内存起始地址指针.
在 Rust 中, 这个指针也是已知大小的, 可以放到栈上, 当然也可以在堆上放指针(比如实现链表时的 next 指针连同数据都放到堆上, 头指针可以在栈上).
由于堆上内存分配前会需要查找可用区域, 因此堆分配比栈分配慢. 且 CPU 和操作系统配合实现的虚拟内存管理机制决定了栈上数据的访问比堆上的更高效.
当代码调用某个函数或方法时, 传入参数的数据会被入栈(指针或 Sized 数据的拷贝), return 后这些数据出栈.
什么是 Ownership?
Ownership 是 Rust 程序用来管理内存的一系列规则. 任何程序都需要在其运行过程中有一套管理内存的方法, 比如某些语言使用 GC, 另外一些语言则由开发者手动管理内存. 而 Rust 则通过可被编译器检查的 Ownership 规则来管理内存, 这样最大的好处是管理内存没有任何额外开销, 且对开发者友好.
需要注意, Rust 的栈上内存管理方式和 C 并无二致, 只是数据的存放方式不同, 因此 Ownership 的讨论主要集中在堆内存的管理上. 后续的讨论均使用非常典型的 string
作为讨论对象, 举一反三.
Ownership 规则
- Rust 中每个数据值(Value) 都有它自己关联的变量(Variable), 此变量称为所有者(owner).
- 在没有使用引用计数智能指针(如 RC/ARC)的情况下, 每个数据值有且只有一个 owner.
- 当 owner 出作用域后, 值占用的内存也会自动被释放.(栈上内存分配和作用域的进出方式相同, 因此不作讨论)
Rust 中变量的作用域和 C 一致, 因此不展开讨论.
String 类型
由于之前介绍的基本数据类型都是固定大小的, 且支持复制的, 在栈上直接分配的. 这里需要一种堆上分配的数据类型, 能够描述 Rust 是如何识别并自动清理内存的, 因此选择 String
进行介绍.
String 常量由于是在代码段中直接存在的且是不可变的, 因此不再讨论.
String 类型的堆分配可以像下面这样:
let s = String::from("hello");
上面这个代码中, 通过字符串常量作为初始化值在堆上分配一块内存. 由 s 变量作为该值的 owner, 且 s 是可变的:
s.push_str(", world");
下面来看看这个字符串具体的内存分配.
String 变量的堆内存分配
由于我们使用 String::from
类方法创建字符串, 该方法内部实现会请求内存分配, 但由于没有 GC, 内存释放一般来说是需要我们手动完成. 手动释放内存存在的问题是:
- 如果我们忘记释放, 会出现内存泄漏
- 如果我们释放早了, 会出现空指针
- 如果我们释放晚了, 会出现无谓的内存消耗
- 如果我们释放了多次, 会出现 double free 问题.
因此手动释放时必须精确对应时机, 且一次分配一次释放匹配.
那 Rust 是如何做到不需要我们手动释放的呢? Rust 会在变量作用域结束后自动释放内存, 自动释放内存时, Rust 会调用一个特殊函数 drop
来完成值的释放.(在某个 item 生命期结束后就释放资源的模式在 C++ 中称为 RAII)
原理上非常简单, 但如果想让分配的值在代码中不同的地方使用(比如传递到另一个函数中), 则情况会变得复杂, Rust 在传递 ownership 时使用的一个概念就是 move
.
Move 的概念
Move 用于传递 ownership, 比如下面的代码:
let s1 = String::from("hello");
let s2 = s1;
在上面的第二行代码执行后, s 对应的值的 ownership 就被 move 给了 s1. 而 s 变量在这一行之后就不可用了.
情况如下图所示:
左侧上栈上的变量, 右侧上堆内存, 其中 ptr 是堆内存指针, len 是当前 String 占用堆内存分配的 Byte 大小, capacity 是当前 String 从内存分配器接收到的堆内存总大小(Byte).
当执行 s2 = s1
后, 实际内存情况是这样的:
即 s1 的值被 move 到 s2, 堆内存不会被复制, 栈上的变量会被复制, 由 s2 指向到堆内存. Rust 在这里做了更多的一步, 它不像其他语言复制指针后会保留之前指针值, 而是直接将被 move 的变量从栈上移除(让 s1 无效). 这一情况就是 Rust 中的 move
, 正式的说法是 "s1 被 move 到 s2".
Rust 永远不会"自动"进行堆内存拷贝, 除非是手动指定. 因为堆内存拷贝是非常昂贵的.
堆内存拷贝: Clone
如果确实需要进行堆内存拷贝, 则需要 调用 clone()
方法进行, 如下所示:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
在绝大部分情况下, 如果看到大量 clone 的调用, 通常都意味着可能代码有需要优化的地方.
栈内存拷贝: Copy
在 Rust 中, 如果变量大小在编译 时已确定, 则它是存放在栈上的, 且 Rust 在赋值或传参或返回等情况下, 都是自动进行的栈内存拷贝. 所有基本类型都默认进行栈内存拷贝, 另外如果实现了 Copy
协议的类型, 则都是进行栈内存拷贝, 因此不会牵扯到 Move.
如果某个类型自己实现了 Drop
trait, 或它的任意成员实现了 Drop
trait, 则它无法实现 Copy
.
Rust 中可以通过如下规则来判断该类型是否实现了 Copy
从而可以自动进行栈内存复制:
- 所有实现了
Drop
或成员中有实现Drop
的类型, 都不是Copy
类型的. - 除 array 外的所有基本数据类型都是
Copy
的.(array 或 tuple 单独讨论) - 所有由除 array 或 tuple 外的基本类型组合而成的数据类型.
- 如果 array 或 tuple 的元素都是基本类型, 则 array 或 tuple 也实现了 Copy.
- 如果组合类型中包含的 array 或 tuple 成员的元素是基本数据类型, 则该组合类型也实现了 Copy.
函数传参和返回
函数的传参/返回值也和赋值时一样, 根据数据类型不同自动进行 move 或栈内存拷贝(Copy).
即所有非 Copy
的类型都会进行 move, 而 只有 Copy 类型会进行栈内存拷贝的方式传参或返回值.
引用和 Borrow
引用是栈上变量的指针, 可以把它理解为一个"栈内存指 针", 如下所示:
在 Rust 中使用 &
获取某个变量的引用, 而通过 *
来接引用获得原来的变量(这个变量可能是栈上的, 也可以是堆内存指针变量).
通过引用可以获取某个变量的值访问途径, 但引用本身不是值的 owner. 因此, 我们将获取变量的引用称为 Borrowing.
引用默认情况下是不可变的, 因此如下代码无法通过编译:
let s = String::from("hello");
change(&s);
// ...
fn change(some_str: &String) {
some_str.push_str("word");
}
只有使用可变引用才能写引用的值.
可变引用
可变引用语法为:
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
即使用 mut 修饰引用, 让其成为: &mut
Rust 中使用可变引用有一个重要限制: 一个变量在其作用域内同时只能有一个可变引用, 且可变引用不能和不可变引用同时存在!
(当然如果各个引用的作用域之间没有重叠的话, 就不会出现 "同时存在" 这一情况)
有这个限制的原因是避免出现数据竞争(data race), 数据竞争和竞争条件类似, 如下三个情况出现后, 表示出现了出现数据:
- 两个或两个以上指针同时访问同一个数据
- 两个以上指针指向同一数据, 至少有一个指针在写数据
- 没有使用任何同步数据访问的手段
数据竞争出现后, 将导致不可预测的行为, 因为数据被改变后的状态是不可预测的.
野指针
在 Rust 中, 编译器能够保证不会出现野指针的情况, 比如下面的代码就不能通过编译:
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
因为 s 在函数结束后出作用域, 其指向的堆内存值就被 drop 了(栈内存中的 s 变量本身也被出栈了), 此时返回它的引用就会出现野指针.
另一类型的引用: Slice(切片)
切片用于引用集合中指定的连续多个元素. 切片是一种引用, 因此不对值有 ownership.
获取集合的切片语法非常简单, 由于字符串实际也是一种集合, 因此语法也是一样:
let s = String::from("hello world");
// [起始index..结束index+1]
let hello = &s[0..5];
let word = &s[6..11];
// 整个字符串的引用
let whole = &s[0..]
// 上面的写法和下面这个效果一样
let len = s.len();
let whole1 = &s[0..len];
// 从开始到第二个字符开始的引用
let sec = &s[..2]
切片在内存中的情况如下所示:
图中 s 是栈上变量, 包含指向堆内存的指针, world 是栈上的切片变量, 同样包含指向堆内存的指针, 只不过指针指向的是引用区域的起始地址, 同时包含区域长度.
比如下面的情况使用切片来处理会更加高效(没有任何堆内存拷贝).
假设我们要返回字符串中第一个单词, 如下所示
/// 获取空格分割的字符串中第一个单词
fn first_word(s &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
// 若当前为空格字符, 则返回到此下标前的字符串引用
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
&str
类型用于表示字符串的切片, 另外还有一个非常重要的性质: Rust 中字符串常量都是 &str(即字符串切片/引用)类型的.
道理也很简单, 因为字符串常量已存在于二进制文件中, 加载到内存后放入 text 段, 在栈里面的变量实际包含指向该 text 段内对应区域的起始指针和字符串长度等信息.
另外注意到我们上面函数参数使用的也是 &str
类型的, 这个是 Rust 中惯用的方式, 这样一来, 这个函数同时可以接受 &String
或 &str
作为参数.(能传入 &String
的原因是自动的 deref coercions
)