所有权是 Rust
很有意思的一个语言特性,但对于初学者却是一个比较有挑战的内容。
今天尝试用代码示例来聊聊 Rust
的所有权是什么,以及为什么要有所有权。希望能给初学的朋友一点帮助。
Tips:文中代码有相应注释,建议可以先不用纠结细节,关注整体。后边可以再挨个去研究具体代码细节
移动?拷贝?
先来试试常规的赋值语句在Rust
有什么样的表现
1 | println!("start"); |
结果是
1 | error[E0382]: use of moved value: `d` |
为什么 code 2
出错了? code 1
没有?
看起来都是初始化赋值操作,分别将数字 a
和字符串 d
多次赋值给别的变量
为什么字符串的赋值失败了。
这里要引出 Rust
世界里对值拷贝和所有的区分
对于一切变量,当把他传递给别的变量或函数,如果他可以拷贝(Copy
)就复制一份;否则就将值的所有权移动(Move
)过去。
这里a
是数字,数字是可以拷贝的,所以 code 1
是可以编译通过的。
而d
是字符串,字符串是不可以拷贝的,第一次赋值就将所有权move给了_e
,只能move
一次,所以 code 2
编译不通过。
为什么要拷贝或移动?先剧透下 Rust
没有内存垃圾回收器(GC
),它对内存的管理就是依赖所有权,谁持有(Own
)变量,谁可以在变量需要销毁时释放内存。
我们拿代码看看它如何销毁变量
作用域和销毁
这里我们关注在何时销毁的
1 | // 因为孤儿原则,包装原生string类型,来支持添加drop trait实现,来观察销毁 |
运行结果是:
1 | Dropping MyData with value: MyString("not used") |
代码分了两个作用域(Scope
)
Tips: 其实有多个,每个
let
也可以看做是一个作用域,这里为了方便理解,只分了两个
main函数自身的
scope
main函数内的
scope
在此作用域内
_
变量的结构体及包含的字符串就销毁了。
这里let _
代表这个变量被忽略,也无法再被别人使用,所以当即销毁离开此作用域时,局部变量
_wrapper
也被销毁
结合之前字符串不能多次移动,这里就展示Rust
对内存管理的两个原则:
- 值只能有一个所有者,当离开作用域,值将被丢弃。
- 所有权可以转移
嗯,这么搞确实很利于内存管理。
那要只是想引用一个变量,不想移动怎么办?(毕竟移动只能一次)
借用
先来看看常规的“引用”
1 | println!("start"); |
这段代码是可以编译通过的
Tips,
Rust
在编译阶段就能分析出很多代码问题,这也是为什么前边的错误里没有打印“start”,因为编译就失败了
Rust
里对“引用”有细分,这里叫借用(Borrow
),至于为什么,我们后边讲
从目前的代码看,如果一个变量借用了字符串变量,这个借用是可以赋值给多个变量的。
这样对于不需要Move
整个字符串,只是要借用值来说,使用确实方便多了,那借用什么时候回收呢?
1 | // 增加一个借用结构体 |
结果是:
1 | End of the scope inside main. |
在销毁借用的变量前,先销毁了所有的借用。哈哈,你可以有多个借用(准确说是不可变借用(immutable borrow
),后边在展开),但销毁变量时,所有借用都会被一起销毁,这样保证你不是借用一个已经销毁的变量(use after free
)
修改
到这里我们都没有修改过一个变量
Rust
能像别的语言这样赋值修改么?
1 | let d = String::from("hello"); |
结果是不行
1 | error[E0384]: cannot assign twice to immutable variable `d` |
Rust
对读取和修改是有区分的,像错误提示那样
需要mut
关键字来声明变量可修改
1 | let mut d = String::from("hello"); |
那对应的销毁时什么样的呢?
1 | fn main() { |
结果是
1 | Dropping MyString with value: "used as mut variable1" |
基本和之前不可变(immutable
)变量销毁类似,唯一不同是赋值后,赋值前的值要被销毁,内存的管理很是细致啊。
现在说了借用,说了可变,我们可以来看看前边提到借用是有区分的:还有一个可变借用(mutable borrow
)
可变借用
对于可变变量,是可以有对应的可变借用的
1 | let mut d = String::from("hello"); |
那如果同时有可变借用和不可变借用,下边的代码可以么?
1 | fn main() { |
答案是不可以
1 | error[E0502]: cannot borrow `d` as mutable because it is also borrowed as immutable |
编译器明确告诉我们,可变借用的时候不能同时有不可变借用。
为什么,如果拿读写互斥锁来类比,就很好理解了,我有可变借用,就像拿到写锁,这个时候是不允许有读锁的,不然我修改和你读取不一致怎么办。
这是就得出了所有权里借用的规则:
- 不可变借用可以有多个
- 可变借用同一时间只能有一个,且和不可变借用互斥
所有权原则
到此,所有权的三条原则就全部出来了
- 值有且只有一个所有者, 且所有者离开作用域时, 值将被丢弃
- 所有权可转移
- 借用
- 不可变借用可以有多个
- 可变借用同一时间只能有一个
这些规则,规范了对于一个变量谁持有,离开作用域是否可以释放,变量的修改和借用有什么样要求,避免释放后的内存被借用,也防止修改和读取的内容不一致有race condition
的问题。
最厉害的是这些都是编译阶段就分析保证了的,提前暴露了问题,不然等到代码上线了遇到问题再crash,追查起来就滞后太久了。
到这所有权就结束了么?还没有,快了,再耐着性子往下看
内部可变性
目前为止,一个借用要么是只读的要么是可写的,限制都很严格,万一我想需要写的时候再可写,平时只要一个只读的借用就可以,能搞定么?
能!
Rust提供了Cell
(针对实现Copy
的简单类型)
和RefCell
(针对任何类型,运行时做借用检查)Arc
(多线程安全的引用计数类型)等类型,来支持内部可变性。Mutex
和RwLock
也是内部可变性的一种实现,只不过是在多线程场景下的。
Tips: 本质上可以理解为对读写互斥的不同粒度下的封装,不需要显式声明可变借用,但内部有可变的能力
以RefCell
为例,来看看内部可变性
1 | use std::cell::RefCell; |
生命周期
终于到了最后一个话题,生命周期
下边一段简单的字符串切片的长度比较函数
你能想到它为什么编译不通过么?
1 | fn longest(str1: &str, str2: &str) -> &str { |
错误是:
1 | error[E0106]: missing lifetime specifier |
编译器再一次友好的提示我们,函数入参两个借用,返回值一个借用,无法确定返回值是用了哪个入参的生命周期。
一个新的概念出现了:生命周期
生命周期是Rust
用来标注引用存活周期,借此标识变量的借用与作用域是否合法,即借用是否在作用域内还有效,毕竟不能悬空指针(dangling pointer
, 借用一个失效的内存地址)啊。
就像这里,函数返回一个借用,那返回的借用是否在作用域内合法,和入参的两个引用的关系是什么,靠的就是生命周期标注。如果入参和出参都是一个生命周期,即出参的借用在入参的借用作用域内,只要入参的生命周期合法,那出参的就是合法的。不然如果出参用了只是借用函数内部变量的生命周期,那函数返回后,函数内部变量就被销毁了,出参就是悬空指针了。
你可以简单理解为给借用多增加了一个参数,用来标识其借用在一个scope
内使用是否合法。
题外话,其实你如果了解
Golang
的逃逸分析,比如当函数内部变量需要返回给函数外部继续使用,其实是要扩大内部变量的作用域(即内部变量的生命周期),不能只依靠当前函数栈来保存变量,就会把它逃逸到堆上。 它做的其实也是变量的生命周期分析,用增加堆的内存开销来避免悬空指针。
只不过那是在gc基础上一种优化,而Rust
则是在编译期就能通过生命周期标注就能确定借用是否合法。
对于想把内部变量返回给外部使用的情况,Rust
也提供了Box
来支持,这里就不展开了。
那是不是每个借用都要标注?
也不是,rust默认会对所有借用自动标注,只有出现冲突无法自动标注的时候才需要程序员手动标注。如果感兴趣的话,可以深入看下Subtyping and Variance,了解下生命周期的一些约束。
最后我们看下下边编译不通过的代码,从编译期的报错你就应该能明白,为什么要生命周期标注了,它对于让编译期做借用的作用域合法性检查很有用。
1 | fn get_longest<'a>(str1: &'a str, str2: &'a str) -> &'a str { |
错误是:
1 | error[E0597]: `str1` does not live long enough |
总结
好了,收个尾吧:
- 所有权关注的是值的拥有和管理
- 借用检查器在编译时保证借用的有效性和安全性
- 生命周期关注的是借用的有效范围和引用的合法性
他们配合在一起,构建起了Rust
强大的内存管理能力。避免了内存泄漏和悬空指针的问题,也避免了GC
带来的性能问题。
怎么样?是不是感觉Rust
的所有权设计还挺有意思的?一个所有权把内存管理的清晰又明了!
欢迎有问题的朋友留言讨论。
如有疑问,请文末留言交流或邮件:newbvirgil@gmail.com 本文链接 : https://newbmiao.github.io/2023/08/13/master-rust-ownership-from-scratch.html