Rust 所有权规则简述
对于低级语言而言,对象的回收往往是一个难题。一个对象创建后,往往会在各个地方传递,由于对象的引用者们生命周期不尽相同,也就不知道何时、由谁来负责对象的回收。
Rust 所有权规则是如何解决这个问题的呢?它把对象分为可变对象和不可变对象,对象的引用者分为所有者和借用者。下面是我根据自己的理解做的总结:
一个对象有且只能有一个所有者,对象的回收由其所有者负责,对象的所有权可以转移。
借用分为可变借用和不可变借用:
- 多个不可变借用可共存;
- 可变借用不可和其他借用共存,无论借用是可变还是不可变;
- 对象所有者不能在不可变借用前写对象,不能在可变借用前读写对象;
- 可变对象可以有可变和不可变借用,不可变对象只能有不可变借用;
对象的所有权转移时,对象的可变性可以发生更改。
不能共存指的是它们的作用域不能有交集。一个变量的作用域从声明的地方开始,到最后一次使用的位置结束,这一点和其他语言不同。
我们知道,编译器是知道栈上每一个变量的生命周期的。对于栈上的每一个变量,如果它是对象的借用者,那么它会在生命周期结束后释放借用,如果是对象的所有者,编译器会在该变量生命周期结束时,调用编译器为对象自动生成的析构函数,析构函数的逻辑是:
- 如果对象实现了
Droptrait,调用它的drop方法以释放其持有的资源。容器类对象通常要在这里手动清理容器内的每一个元素,然后再清理缓冲区本身。之所以这样做是因为编译器不感知缓冲区内的数据到底是对象,还是图片等其他类型的数据,因此没法针对缓冲区对象生成清理逻辑。rust 提供了一个方法辅助容器类对象回收内部的元素:drop_in_replace(),该方法会对元素执行和这里相同的逻辑。 - 如果对象包含其他对象,按声明顺序执行这些对象的析构函数。
- 否则啥也不做,让对象随着栈帧或者外层对象的回收而回收即可。
结合代码体会上述概念:
1 | fn main() { |
Rust 中的赋值操作默认使用的是 move 语义,move 语义就是用来实现资源所有权的转移。除了赋值,传参、return 这些操作使用的也都是 move 语义,除非类型实现了 Copy trait,那就使用 copy 语义,copy 语义不会转移原变量的所有权,原变量依然持有原对象的所有权,新变量拥有的是拷贝后的对象的所有权,例如:
1 | fn main() { |
我们看下在借用前后读写原对象的情形:
1 | struct Person { |
转移所有权时可改写对象可变性:
1 | fn main() { |
析构逻辑验证:
1 | struct MyStruct { |
相关语义的实现
copy 语义
按位复制对象,即浅拷贝。因其浅拷贝的特性,Rust 有一条规则:实现了 Copy trait 的对象,不允许再实现 Drop trait,且内部对象也必须实现 Copy trait,也就意味着内部对象也不允许实现 Drop trait,如此递归下去……
如何理解这条规则?我们反过来想,一个对象如果实现了 Drop trait,它一定有独立于对象之外的资源需要释放,而 Copy trait 属于浅拷贝,只会按位复制对象本身,包括资源句柄(例如文件描述符,堆内存指针,socket 描述符等)。相同的资源句柄往往指向同一资源,因此 Copy 后两个对象共享外部资源。我们知道,编译器会在所有权变量作用域失效的位置插入 drop调用释放其持有的外部资源,如果 copy 后的两个所有权变量都执行了 drop,就会触发共享资源的双重释放。通常来说,资源重复释放是不允许的,比如堆内存就是这样,因此禁止实现了 Copy trait 同时实现 Drop trait 是一个很合理的做法。那是否存在这样一种情况:对象有需要释放的资源,它必须实现 Drop trait,但又想实现拷贝?有的,但这种情况往往隐含了一个前提,就是资源是非共享的,此时开发者应实现 Clone trait 而不是 Copy trait 来实现资源的拷贝,即深拷贝。
以下对象默认实现了 Copy trait:
- 基本标量类型
- 元组(当所有元素都实现 Copy 时)
- 数组(当元素类型实现 Copy 时)
- 不可变引用 (&T)
- 函数指针 (fn)
- 裸指针 (*const T, *mut T)
- Never 类型 (!)
- 单元类型 (())
- 标记类型(如 PhantomData)
一般来说,类型的使用者通常不需要关心其是否实现 Copy trait,否则写代码时的心智负担就太重了。
move 语义
实际上是编译时检查 + 运行时浅拷贝实现的,原有对象其实还在内存中躺着,只是编译器不会让你继续使用所有权被转移的变量(除非对象实现了Copy trait)。
感觉 move 语义完全可以只在编译器层面实现,只要编译器确保原有变量不可用,运行时就可以复用它持有的对象,而不是拷贝一份。难道是为了模仿 C ++ 的移动语义,确保兼容性?
可以用以下代码来验证:
1 | fn main() { |
运行结果:
1 | ---------- 所有权转移前 --------- |
可见,move 语义只是将 String 对象在栈上浅拷贝一份,且原有对象不会被清理也没有清理的必要。
从打印结果可以看出,String 对象在栈上内存布局从低到高依次是:capacity (u64),ptr(u64),len(u64),这和官方示意图不一样,不知为何 🤨。
但并不是所有情况下,move 语义都会用运行时的浅拷贝来实现。实测发现,在传参的时候,如果对象的大小大于 8 字节,不会触发浅拷贝,move 语义仅停留在语法层面,测试代码如下:
1 | struct M(u16, u16, u16, u8, u16); // 9 字节 |
输出:
1 | # M 大于 8 字节时,传参时 move 前后对象地址一致 |
以上分析是基于原对象在栈上分配的情形,如果原对象是堆上分配的,原理应该也差不多。