掌握Rust:从零开始的所有权之旅

2023-11-27 12:29:30 浏览数 (2)

所有权是 Rust 很有意思的一个语言特性,但对于初学者却是一个比较有挑战的内容。

今天尝试用代码示例来聊聊 Rust 的所有权是什么,以及为什么要有所有权。希望能给初学的朋友一点帮助。

Tips:文中代码有相应注释,建议可以先不用纠结细节,关注整体。后边可以再挨个去研究具体代码细节

文章目录

  • 移动?拷贝?
  • 作用域和销毁
  • 借用
  • 修改
  • 可变借用
  • 所有权原则
  • 内部可变性
  • 生命周期
  • 总结

移动?拷贝?

先来试试常规的赋值语句在Rust有什么样的表现

代码语言:javascript复制
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整个字符串,只是要借用值来说,使用确实方便多了,那借用什么时候回收呢?

代码语言:javascript复制
// 增加一个借用结构体
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能像别的语言这样赋值修改么?

代码语言:javascript复制
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关键字来声明变量可修改

代码语言:javascript复制
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(多线程安全的引用计数类型)等类型,来支持内部可变性。MutexRwLock也是内部可变性的一种实现,只不过是在多线程场景下的。

Tips: 本质上可以理解为对读写互斥的不同粒度下的封装,不需要显式声明可变借用,但内部有可变的能力

RefCell为例,来看看内部可变性

代码语言:javascript复制
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

0 人点赞