Dive into Rust: Ownership, Borrowing, Lifetime

创建于:发布于:文集:Dive Into Rust

最近在Rust中文论坛看到一位Java程序员的提问,主要是有关Rust所有权规则的理解问题,想了想,对于常用Java、Python这类有垃圾回收机制语言的程序员,Rust独特的内存安全机制确实较难理解。这篇博客准备总结一下我对Rust内存安全问题的理解,提供一些从Python分析Rust的视角,抛砖引玉。

常见内存安全问题

先列举一下一些常见的内存安全问题:

对于这些内存安全问题,一般常见的编程语言有以下解决方案:

  1. 程序员自己负责:C/C++系,通常是系统级编程语言的做法,程序员自己注意这些问题,优点是没有运行时负担,这也是为什么一般都是需要高性能的系统级语言采用这种做法,缺点很明显,高度依赖程序员的细心程度,一不小心就出问题,并且很有可能项目上线很久才会发现
  2. 垃圾回收:典型代表Java,由语言提供运行时管理内存,例如在一个值不会再被访问时释放该空间,优点是不需要程序员手动管理,心智负担小,缺点是牺牲了性能(其实还是会有内存泄漏等问题
  3. Rust:Rust表示鱼和熊掌要兼得,提倡零成本抽象,提供一套特定的规则,只有遵守规则的程序才能通过编译,在编译期解决内存安全问题,性能没有牺牲,程序员脑子也没有牺牲(大概

下面就聊一聊Rust用来保障内存安全的机制:所有权(Ownership)、借用(Borrowing)、生存期(Lifetime)

所有权

首先看一段Python代码:

import sys

class Foo:
    def __init__(self, value: int) -> None:
        self.value = value


a = Foo(3)
print(sys.getrefcount(a))
b = a
print(sys.getrefcount(a))

输出结果是2和3,Python的垃圾回收机制主要是基于引用计数,简单理解就是把变量看成对实际内存中值的一个引用,那么当这个引用数量为0时,这个值也就不需要了,可以从内存中清理掉。上面我们创建了Foo(3)这个值,赋值给a变量,这里结果是2,官方文档解释是因为在调用sys.getrefcount时参数作为临时变量引用,使引用计数被加了一次,后面又赋值a给了b,引用计数又加了1。

上面是Python中的规则,当然解释器实际的垃圾回收是很复杂的,还有一些其它辅助手段,但总之这个过程是没有我们人为干预的。

下面再看一段Rust代码:

#[derive(Debug)]
struct Student {
    name: String
}

fn print_student(student: Student) {
    println!("{:?}", student);
}

fn main() {
    let ming = Student { name: String::from("ming") };
    print_student(ming);
    let hong = &ming;  // 试着改成let hong = ming;再看报错
    println!("raw: {:?}, borrow: {:?}", ming, hong);
}

我特意写了个错误明显的程序,可以分别剖析,首先直接运行这段程序,看下编译器报错:

error[E0382]: borrow of moved value: `ming`
  --> src/main.rs:13:16
   |
11 |     let ming = Student { name: String::from("ming") };
   |         ---- move occurs because `ming` has type `Student`, which does not implement the `Copy` trait
12 |     print_student(ming);
   |                   ---- value moved here
13 |     let hong = &ming;  // 试着改成let hong = ming;再看报错
   |                ^^^^^ value borrowed here after move

Rust的编译器错误提示非常清晰,这里告诉我们,ming拥有类型Student的一个实例,在调用print_student(ming);时发生了“move”,并且还给了个提示说是因为我没有为其实现Copy trait,接着又告诉我们,在第13行尝试在发生“move”后借用这个值。对于不熟悉Rust的同学会觉得不知所有,这里先提一下Rust的所有权规则(来自《Rust程序设计语言》):

  1. Rust 中的每一个值都有一个被称为其 所有者owner)的变量。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。

根据规则一,我们知道在定义变量ming的时候,ming就是实际的Student值的所有者,享有所有权,根据规则二,我们知道,在任何时候,Student实际值,都只能有一个主人,再回顾一下编译器提示:move occurs because ming has type Student, which does not implement the Copy trait。

对于Python程序:

a = [1, 2, 3, 4]
b = a
print(id(a), id(b))

Python中可以认为任何变量实质上都类似于C中的指针,只是指向实际值的一个标记,所以这里打印a和b指向的内存中的位置是相同的,a就是b。

而在Rust中:

fn main() {
    let ming = Student { name: String::from("ming") };
    let ming2 = ming; // 此时ming没用了
    // println!("{}", ming); 这行会报错
    println!("{:?}", ming2);
}

let ming2 = ming;,根据规则二,一个值同一时间只能有一个所有者,所以这里ming2成了所有者,所有权“move”了,ming不再存在,编译器提到了Copy,只有实现了Copy的数据类型,才会在这种重新赋值的场景下,不“move”所有权,而是复制一份新的值。

fn main() {
    let a = 1;
    let b = a;
    println!("a: {}, b: {}", a, b);
}

上面这段代码就不报错,因为Rust中一些基础类型是实现了Copy的,所以let b = a;的时候,b拥有所有权的是一个被复制出来的1,不违反规则二。

调用print_student函数,就是把ming的所有权“move”给了这个函数的参数这个变量,再看看规则三,首先什么是作用域呢?作用域就是一个值在程序中有效的范围:

{                      // s 在这里无效, 它尚未声明
    let s = "hello";   // 从此处起,s 是有效的

    // 使用 s
}                      // 此作用域已结束,s 不再有效

可以简单理解成Rust中任何值遇到右花括号}就被销毁死掉,Rust强制执行这一规则,那么在print_student函数结束的时候,实际的student值在内存中不复存在了,可是我们却要在这之后再次使用ming,于是编译器报错,但是注意,编译器给出的实际错误并不直接说student已经不复存在,而是说我borrowed after move,什么是borrow下一步说。

要解决这个问题,可以在print_student结束的时候用返回值,将所有权再还回来:

fn print_student(student: Student) -> Student {
    println!("{:?}", student);
    student
}

fn main() {
    let ming = Student { name: String::from("ming") };
    let ming = print_student(ming);
    let hong = &ming; // 这个时候ming还存在
    println!("raw: {:?}, borrow: {:?}", ming, hong);
}

这样就不会报错了,但是难道Rust中每个函数都必须将参数当做返回值返回吗?

还有一个办法:

#[derive(Debug, Clone)]
struct Student {
    name: String
}

fn print_student(student: Student) -> Student {
    println!("{:?}", student);
    student
}

fn main() {
    let ming = Student { name: String::from("ming") };
    print_student(ming.clone()); // 这里传入的是复制体
    let hong = &ming;
    println!("raw: {:?}, borrow: {:?}", ming, hong);
}

可以为Student实现Clone这个trait(关于Copy,有一套特殊规则,详见文档),在传入参数时显式调用clone方法,把复制体传进去。

但这样还要在每次调用函数时复制一份原有的值,还有其它方法吗?

借用

一般大学计算机相关专业会以C语言作为学生的入门语言,我们在学习C语言的时候,一般都会被告知使用malloc分配的内存,一定要记得free掉,程序运行的时候,不同的数据会使用不同的内存,如何在数据不再需要时,释放内存一直是一个麻烦的问题,忘了释放会浪费内存;释放过一次但程序员忘记了,再次释放,这是个未定义行为;数据后面还需要用却提前释放内存了,那更是可怕。前面我们提到Rust的所有权规则第三条,离开作用域就是释放内存,这听上去很简单,但从之前的代码中,这似乎会在一些场景下为我们带来麻烦。

前面我们已经提到了Rust中多个变量与数据之间的两种关系,分别是移动(move)与克隆(clone)。

let a = String::from("hello");
let b = a; // move

let a = String::from("hello");
let b = a.clone(); // clone

我们已经发现在使用函数时,所有权规则给我们带来了一些麻烦,于是Rust给我们提供了另一个机制。

    let hong = &ming; // 这个时候ming还存在

还记得这一行代码以及那个错误提示“value borrowed here after move”吗?&变量表示对变量的引用(reference),&ming创建了一个指向ming的引用,将它赋值给hong,hong并不会获得实际值的所有权,因此不违反所有权规则二,引用有些像C中的指针,但并不相同。

在讲引用前,要讲一下Rust变量的不可变性,在Python中,实际上是有不可变对象与可变对象之分的,但是没有不可以修改的变量这个概念(要想实现一个运行时不能被修改的类变量需要一些骚操作):

// 可以随意更改a的值和类型
a = 1
a = 2
a = "hello world"

因为变量只是一个“指针”,即使它指向的是不可变对象,也可以随意改变它所指向的数据。

let a = 1;
a = 3; // 错误

let a = 3; // 但是这种覆盖式的初始化是允许的

默认情况下,Rust定义一个变量之后就不允许对其进行修改,不仅是类型不可变,值也不能改。

let mut a = 1;
a = 2;

必须使用mut(mutable)关键字,显式标识这个值可以被修改。那么这和引用有什么关系呢?

以后端常说的增删改查来说,如果一个数据,需要被读取10次,这没太大问题,因为读这个操作没有副作用,不会改用数据本身,我们常说HTTP的GET方法具有幂等性也是这个原因,可以如果是修改呢?删除呢?

let a = 1;
let borrow_a = &a; // 不可变借用
let borrow_a1 = &a; // 没问题

let mut b = 1;
let mut_borrow_b = &mut b; // 可变借用
let mut_borrow_b1 = &mut b; // 错误

let c = 1;
let mut_borrow_c = &mut c; // 不行

对于不可变借用,可以有很多个,但同一时间可变变量的可变引用只能有一个,不可变数据没这个限制,因为它就不能有可变引用。

这一条规则避免了数据竞争

Rust直接避免了以上情况,有以上行为的程序无法编译通过。

Rust引用的另一条规则是,引用必须总是有效的,这个怎么理解呢?再看我们之前出错的程序,let hong = &ming;,hong是一个引用,在这之前ming已经在print_student函数中被销毁了,这在内存安全问题中一般被称为悬垂指针或悬垂引用,C语言中,一个指针指向的内存可能被你手动释放或者分配给其它数据了,但是这个指针没有被更改,程序可以编译通过,这会造成难以排查的bug。Rust在编译前避免这一点。

现在我们可以理解为什么编译器报错“borrowed after move”了。

那什么是借用呢?就是在函数使用引用做参数的时候称之为借用,我一般统统叫借用,有借有还,a借给b,所有权最终还在a那里,之前的函数可以这么改:

fn print_student(student: &Student) {
    println!("{:?}", student);
}

fn main() {
    let ming = Student { name: String::from("ming") };
    print_student(&ming);
    let hong = &ming;
    println!("raw: {:?}, borrow: {:?}", ming, hong);
}

参数使用&Student而不直接使用Student。

生存期

很多地方都喜欢把Rust中的Lifetime翻译成生命周期,但是我觉得这个Lifetime和life cycle并不是一个意思,而life cycle一般才被翻译成生命周期,lifetime还是叫生存期比较好。

什么是生存期呢?简单讲生存期基本上就是作用域,不过生存期一般是用来描述一个引用的作用范围,说白了就是一个引用可以合法的活多久。

那么结合上文引用的第二条规则,就能用生存期来描述了:引用的生存期,绝对不能大于其引用的值的作用域,直白说就是&a不能比a活得长。

{
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}  

以上程序来自《Rust程序设计语言》,根据所有权规则第三条,r从声明那一刻可以一直“活”到最后一个右花括号,而它引用的变量x,只能活到倒数第二个右花括号,这样的程序自然是无法通过编译的。

生存期注解

考虑这样一个函数:

fn main() {
    let a = 10;
    let b = 18;
    println!("max number is {}", max(&a, &b));
}

fn max(left: &i32, right: &i32) -> &i32 {
    if left > right {
        left
    } else {
        right
    }
}

编译器会告诉我们:

7 | fn max(left: &i32, right: &i32) -> &i32 {
  |              ----         ----     ^ 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 `left` or `right`
help: consider introducing a named lifetime parameter
  |
7 | fn max<'a>(left: &'a i32, right: &'a i32) -> &'a i32 {
  |       ^^^^       ^^^^^^^         ^^^^^^^     ^^^

编译器直接告诉我们要怎样修改程序:

fn max<'a>(left: &'a i32, right: &'a i32) -> &'a i32 {
    if left > right {
        left
    } else {
        right
    }
}

&'a i32这样的形式被称为生存期注解,这种情况下,我们告诉编译器,函数返回值至少会和两个参数里寿命最短的那个活一样长。这样编译器就能发现以下这种错误:

fn main() {
    let a = 10;
    let max_number;
    {
        let b = 18;
        max_number = max(&a, &b);
    }
    println!("max number is {}", max_number);
}

编译器会报错:

6 |         max_number = max(&a, &b);
  |                              ^^ borrowed value does not live long enough
7 |     }
  |     - `b` dropped here while still borrowed
8 |     println!("max number is {}", max_number);
  |                                  ---------- borrow later used here

编译器告诉我们这个借用活得不够长,如果这段程序被允许编译通过,那么就会造成悬垂引用问题了,如果a确实大于b那就还好,反过来,则会出现b被销毁,而b的借用还活着的情况。

在未来的Rust程序中,手动标记的生存期将会越来越少,编译器会自动帮我们推断出大部分情况下借用的生存期。

总结

下面是文章开头提到的那位Java程序员提问的代码:

struct InnerA {
}
struct InnerB<'a> {
    inner_a: &'a InnerA,
}
struct Outer {
    inner_a: InnerA,
    inner_b: InnerB,
}
fn main() {

    let inner_a = InnerA {};
    let inner_b = InnerB {
        inner_a: &inner_a,
    };

    let outer = Outer {
        inner_a,
        inner_b,
    };
    println!("OK");
}

编译器提示需要指定生存期,接着他改成了这样:

struct InnerA {
}
struct InnerB<'a> {
    inner_a: &'a InnerA,
}
struct Outer<'b> {
    inner_a: InnerA,
    inner_b: InnerB<'b>,
}
fn main() {

    let inner_a = InnerA {};
    let inner_b = InnerB {
        inner_a: &inner_a,
    };

    let outer = Outer {
        inner_a,
        inner_b,
    };
    println!("OK");
}

想必看到这里,读者已经能看出程序的错误之处以及如何修复了,当然具体的修改方式还得视需求而定。

Rust的内存安全机制还是比较独特的,尤其是熟悉Python这类语言的程序员初次接触会感到比较陌生。

在此总结一下:

  1. 数据同一时间有且只能有一个所有者,Python程序员尤其注意
  2. RAII,数据离开作用域立即被释放内存,Python程序员可以理解成语言层面的一个上下文管理器,离开with语句就调用__exit__方法
  3. 数据默认不可变,同一时间只能有一个可变引用,可以认为没有实际上的可变数据,只有共享数据与独占数据之分,一个数据能被多个人访问的时候不能变,只有有唯一可变引用或者没有引用的时候,这时候是**“独占”**的,所以可以安全地修改。
EOF
Github
Copyright © 2020-2024 Elliot