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