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

查看 10|回复 0
作者:newmiao   
所有权是 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;
结果是
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)变量,谁可以在变量需要销毁时释放内存。
我们拿代码看看它如何销毁变量
作用域和销毁
这里我们关注在何时销毁的
// 因为孤儿原则,包装原生 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.");
}
运行结果是:
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对内存管理的两个原则:
  • 值只能有一个所有者,当离开作用域,值将被丢弃
  • 所有权可以转移

    嗯,这么搞确实很利于内存管理。
    那要只是想引用一个变量,不想移动怎么办?(毕竟移动只能一次)
    借用
    先来看看常规的“引用”
    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 {
        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.");
    }
    结果是:
    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");
    结果是不行
    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");
    那对应的销毁时什么样的呢?
    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.");
    }
    结果是
    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)
    可变借用
    对于可变变量,是可以有对应的可变借用的
    let mut d = String::from("hello");
    let g = &mut d;
    *g = "world".to_string();
    那如果同时有可变借用和不可变借用,下边的代码可以么?
    fn main() {
        let mut d = String::from("hello");
        let e = &d;
        let f = &d;
        let g = &mut d;
        *g = "world".to_string();
        println!("{f}");
    }
    答案是不可以
    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);
    生命周期
    终于到了最后一个话题,生命周期
    下边一段简单的字符串切片的长度比较函数
    你能想到它为什么编译不通过么?
    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);
    }
    错误是:
    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(str1: &'a str, str2: &'a str) -> &'a str {
      |           ++++        ++             ++          ++
    编译器再一次友好的提示我们,函数入参两个借用,返回值一个借用,无法确定返回值是用了哪个入参的生命周期。
    一个新的概念出现了:生命周期
    生命周期是Rust用来标注引用存活周期,借此标识变量的借用与作用域是否合法,即借用是否在作用域内还有效,毕竟不能悬空指针(dangling pointer, 借用一个失效的内存地址)啊。
    就像这里,函数返回一个借用,那返回的借用是否在作用域内合法,和入参的两个引用的关系是什么,靠的就是生命周期标注。如果入参和出参都是一个生命周期,即出参的借用在入参的借用作用域内,只要入参的生命周期合法,那出参的就是合法的。不然如果出参用了只是借用函数内部变量的生命周期,那函数返回后,函数内部变量就被销毁了,出参就是悬空指针了。
    你可以简单理解为给借用多增加了一个参数,用来标识其借用在一个scope内使用是否合法。

    题外话,其实你如果了解Golang的逃逸分析,比如当函数内部变量需要返回给函数外部继续使用,其实是要扩大内部变量的作用域(即内部变量的生命周期),不能只依靠当前函数栈来保存变量,就会把它逃逸到堆上。 它做的其实也是变量的生命周期分析,用增加堆的内存开销来避免悬空指针。
    只不过那是在 gc 基础上一种优化,而Rust则是在编译期就能通过生命周期标注就能确定借用是否合法。
    对于想把内部变量返回给外部使用的情况,Rust也提供了Box来支持,这里就不展开了。

    那是不是每个借用都要标注?
    也不是,rust 默认会对所有借用自动标注,只有出现冲突无法自动标注的时候才需要程序员手动标注。如果感兴趣的话,可以深入看下Subtyping and Variance,了解下生命周期的一些约束。
    最后我们看下下边编译不通过的代码,从编译期的报错你就应该能明白,为什么要生命周期标注了,它对于让编译期做借用的作用域合法性检查很有用。
    fn get_longest(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);
    }
    错误是:
    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的所有权设计还挺有意思的?一个所有权把内存管理的清晰又明了!
    欢迎有问题的朋友留言讨论。
    文章首发公众号:newbmiao
  • 您需要登录后才可以回帖 登录 | 立即注册

    返回顶部