Rust 生命周期管理

2023-06-15 rust language

所有权 Ownership 是 Rust 的核心功能之一,使得 Rust 无需垃圾回收,却仍然可以保证内存安全。

所有权

Rust 主要解决的是两个问题:A) 如何安全地进行系统编程;B) 如何更容易并发。而 Rust 通过 Ownership 同时解决了这两个问题,也就是,让 Rust 安全的同时可以直接解决并发问题。

所有权的规则为:

  • 每个值都有一个被称为其所有者的变量。
  • 值在任一时刻有且只有一个所有者。
  • 当所有者离开作用域时,这个值将被丢弃。

在 Rust 中,字符串字面值硬编码在可执行文件中,不可变化,不过可以通过 String 类型,该类型会分配到堆上,能够动态处理变长的字符串,可以通过 let mut s = String::from("Hello") 进行定义。

默认会使用移动操作,对于像 String 这种在堆上分配的内存,在移动之后原值将失去所有权,也就无法再使用;当然,也可以通过 clone() 函数完全复制一份。

fn main() {
    let s1 = String::from("Hello World");
    let s2 = s1;
    let s3 = s2.clone();

    println!("{}", s2); // s1的所有权已经转移,无法直接打印s1变量
    println!("{}", s3); // s3是复制了一份,所以s2和s3都可以使用
}

对于一些基础的变量类型,实际上已经实现了 Copy Trait,那么其通过赋值移动实际上是不影响原变量的,包括了整型、浮点、布尔、字符以及包含这些类型的元组。

fn main() {
    let s1 = 10;
    let s2 = s1;

    println!("{} {}", s1, s2); // 整型实现了Copy Trait,赋值时没有发生所有权的转移
}

所有权转移

赋值、函数入参、函数返回等都会发生所有权的转移。

fn say_hi(s: String) -> String {
    println!("Hi {}!", s);
    s
}

fn main() {
    let s1 = String::from("Andy");
    let s2 = say_hi(s1);
    //println!("{}", s1); // 所有权已转移会报错
    println!("{}", s2); // 所有权通过返回值返回
}

有时候仅仅是使用某个变量,而不是将所有权进行转移,那么此时就可以使用引用,类似于其它语言中的指针。

fn main() {
    let x = String::from("Hello");
    let x1 = &x;
    let x2 = x1; // 引用有Copy Trait可以直接赋值
    println!("{} {} {}", x, x1, x2);

    let mut site = String::from("HelloWorld");
    let domain = &mut site; // 可以修改,引用对象也必须是mut才可以
    domain.push_str(".com");
    println!("{}", domain);
}

生命周期

与大多数语言类似,每个函数都会有一个作用域,也可以在函数内使用一对花括号来内嵌一个作用域,在作用域中声明的变量会在作用域结束时结束。再结合到借用的概念,就会约束借用方的生命周期不能比出借方还要长。

fn main() {
    let a;             // -------------+-- a start
    {                  //              |
        let b = 5;     // -+-- b start |
        a = &b;        //  |           |
    }                  // -+-- b over  |
    println!("{}", a); //              |
}                      // -------------+-- a over

其中 a 是借用方而 b 是出借方,此时借用方的生命周期要比出借方长,会导致悬垂引用。

函数

比较容易出问题的是再结合函数时,其入参是借用方,而返回参数是出借方,例如如下的示例。

fn longer(x: &String, y: &String) -> &String {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let x = String::from("Hi");         // -------------+-- x start
    let max;                            // -------------+-- max start
    {                                   //              |
        let y = String::from("Hello");  // -------------+-- y start
        max = longer(x, y);             //              |
    }                                   // -------------+-- y over
    println!("max {}", max);            //              |
}                                       // -------------+-- max, x over

因为不知道 longer() 函数返回值的声明周期是什么,有可能是新建的,也有可能是与入参相同,所以,如果直接编译会报 error[E0106]: missing lifetime specifier 错误,

此时需要通过函数后的 <> 来指明生命周期,后面的参数以及返回值需要通过 ' 引用即可。

fn longer<'a>(x: &'a String, y: &'a String) -> &'a String {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let x = String::from("Hi");
    let max;
    {
        let y = String::from("Hello");
        max = longer(&x, &y);
    }
    println!("max {}", max);
}

上述意味着入参 a b 的生命周期和返回值相同,但是入参的生命周期不同,此时会选择较小的一个,所以,上述编译会报 error[E0597]: y does not live long enough 的错误。

如果有多个生命周期参数,那么就需要标注各个参数之间的关系。

fn longer<'a, 'b: 'a>(x: &'a String, y: &'b String) -> &'a String {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() { 
    let x = String::from("Hi");
    let y = String::from("Hello");
    let max = longer(&x, &y);
    println!("max {}", max);
}

如上通过 'b: 'a 标识 'a 的生命周期不能超过 'b

结构体

如果结构体中包含引用成员,那么就必须保证结构体本身的生命周期不能超过任何一个引用成员的生命周期,否则,会出现成员销毁后结构体仍然会引用成员,同样造成悬垂引用。

#[derive(Debug)]
struct Foo<'a> {
    v: &'a i32
}

fn main() {
    let foo;                    // -------------+-- foo start
    {                           //              |
        let v = 123;            // -------------+-- v start
        foo = Foo {             //              |
            v: &v               //              |
        }                       //              |
    }                           // -------------+-- v over
    println!("foo: {:?}", foo); //              |
}                               // -------------+-- foo over

结构体变量 foo 的生命周期还没有结束,但是其对应的成员变量引用已经结束,所以会报错。

Static

这是一个特殊的生命周期,与整个应用相同,存储在静态段中,可以通过类似 let s: &'static str = "Hello" 这种方式声明,因为所有的字符串字面值实际都是静态的,所以可以简写为 let s: &str = "Hello" 。 另外,通过 static 所声明变量的生命周期也是 'static 的,例如 static v: i32 = 123

Trait

描述了抽象接口,可以被类型继承,所有的 Trait 都定义了一个隐含类型 Self,用来指向该 Trait 的类型,而且为了方便使用,同时提供了 self,此时 fn foo(self) {} 等价于 fn foo(self: Self) {},同理 &self 等价于 self: &Self&mut self 等价于 self: &mut Self

// trait Shape: Hello 约束只有实现了Hello这个trait类型才能实现Shape这个trait类型
trait Shape {
    // 用途1: 接口抽象
    fn area(&self) -> i32;

    // 默认实现,可以被覆盖
    fn info(&self) -> String {
        String::from("Shape")
    }
}

struct Rectangle {
    width: i32,
    length: i32,
}

impl Shape for Rectangle {
    fn area(&self) -> i32 {
        self.width * self.length
    }
	// Overide
	fn info(&self) -> String {
        String::from("Rectangle")
    }
}

// 用途2: 泛型约束,多个通过 + 连接
fn dump<T: Shape>(s: &T) {
    println!("{} -> {}", s.info(), s.area());
}
// 这里标识入参是特性参数,这只是上述的语法糖
fn dump2(s: impl Shape) {
    println!("{} -> {}", s.info(), s.area());
}

fn main() {
    let rect = Rectangle {
        width: 10,
        length: 20,
    };
    dump(&rect);
    dump2(rect);
}

另外,有个孤儿规则,就是在为某类型实现某 trait 时,两者至少有一个在当前 crate 中定义,否则会报错。

结合泛型

上述的类型中只定义了 i32 类型的长度,那么使用时还可能是其它类型,此时就可以结合泛型来使用。

use std::ops::Mul;

trait Shape<T> {
    fn area(&self) -> T;
}

struct Rectangle<T> {
    width: T,
    length: T,
}

impl<T> Shape<T> for Rectangle<T>
where
    T: Mul<Output = T> + Copy,
{
    fn area(&self) -> T {
        self.width * self.length
    }
}

fn dump<S: Shape<i32>>(s: &S) {
    println!("-> {}", s.area());
}

fn main() {
    let rect = Rectangle {
        width: 10,
        length: 20,
    };
    dump(&rect);
}

在实现时对类型进行了约束,需要支持 std::ops::Mul 的 trait,而且输入和输出的数据类型是相同的,所以实现 std::ops::Mul<Output = T> 的 trait 即可。

生命周期接口

在函数中,对于像 i32 类型的变量是保存在 Stack 中的,默认实现了 copy 接口,而像 String 这类,除了在 Stack 中保存了元数据,还会在 Heap 中保存真实的数据,在赋值语句中,可能调用的是 copy 或者 move 接口。

使用过程中,比较容易混淆的是 move copy clone drop 几个接口,尤其再结合了闭包捕获的场景,简单整理如下:

  • move copy 主要用在变量赋值、函数入参、函数返回值、闭包捕获场景中。
  • clone 需要显示调用。
  • drop 退出变量作用域时会自动调用。
  • 闭包中使用外部变量会自动捕获。

如果类型没有实现 copy trait 的话(很多基本类型都实现了该语义),那么变量赋值、函数入参、函数返回值都是 move 语义,而 C++11 在出现了右值引用之后才有了 move 语义。

注意,dropcopy 只能二选一,一般来说 copy 不会执行类似 clone 的深度复制。如果两个都实现了,而且 copy 是没有实现深度复制的,那么变量在退出时会执行 drop,该方法会清空栈空间,就会导致重复释放内存。

另外,clonecopy 的 super trait,也就是说,如果要实现 copy 那么就必须要实现 clone,前者用于 Stack 中的按位复制,而后者则可能要进行深度复制。

Partial Move

#[derive(Debug)]
struct Person {
    age: Box<u8>,
    name: String,
}

fn main() {
    let person = Person {
        age: Box::new(20),
        name: String::from("Alice"),
    };
    let Person { name, ref age } = person;
    println!("Name={} Age={}", name, age);
    //println!("Person.Name={}", person.name); // moved无法使用
    println!("Person.Age={}", person.age);     // 非moved操作,仍然可以使用
}

如上的结构体 Person 中,在获取 age 参数时使用的是引用,而 name 则是 move 操作,这就导致变量中的 person.name 没有了所有权,也就是所谓的 Partial Move 操作了。

另外比较常见的是在模式匹配中,例如如下示例。

fn main() {
    let mut data = 10;
    let var = Some(&mut data);
    match var {
        None => (),
        Some(ref z) => {
            println!("{}", z);
        }
    }
    println!("{:?}", var);
}

如果不添加 ref 上述的代码会报错,因为匹配到 Some 之后,会将其中的值读取出来,但是原结构体不变,就是所谓的 Partial Move 了。而添加了 ref 就表示只是引用,并不会发生所有权的转移。

其它

如上已经介绍了泛型特征 Generic Traits、类型关联特征 Associated Type Traits,还有如下的常见用法:

  • 特征是可以支持继承的。
  • Marker Traitsstd::marker 模块中定义,不包含任何方法,用于获取一些编译期间的保障。
  • 使用时存在两个比较特殊的生命周期声明:A) 'static 整个应用生命周期;B) '_ 编译过程自动推导。

解引用

与其它语言类似,通过 & 表示引用/借用,而 * 解引用,常规的引用是一个指针类型,包含了目标数据的内存地址。而且读取变量的时候,即使有多层引用,Rust 会自动进行解引用,智能指针也支持。

fn main() {
    let v = 5;
    let x = &x;
    let y = &&x;
    let z = &&&x;

    println("v={} x={} y={} z={}", v, *x, **y, ***z);
    println("v={} x={} y={} z={}", v, x, y, z);
}

其中的 y 就是一个常规引用,包含的是 5 所在的地址,通过解引用 *y 获取到了对应的值 5,如果执行 assert_eq!(5, y) 就会报错,因为引用和数值不是一个类型。

ref

除了上述的 & 操作之外,还支持 ref 来定义指针,两者的使用区别详见如下代码。

fn foobar1(x: &mut i32) {
    *x = 91;
}

fn foobar2(ref mut x: i32) {
    *x = 92;
}

fn foobar3(ref mut x: &mut i32) {
    **x = 93;
}

fn main() {
    let mut a: i32 = 0;

    // & 和 ref 获取指针作用相同,只是在不同位置
    let ptr1 = &mut a;
    *ptr1 = 1; // 指针不可修改,但是指针指向内容可以
    let ref mut ptr2 = a;
    *ptr2 = 2; // 同上

    foobar1(&mut a); // 可以修改变量
    foobar2(a); // 无法修改变量
    foobar3(&mut a); // 可以修改,但使用比较复杂
    println!("result = {}", a);
}

其中的 foobar2() 函数语义是将变量复制到栈空间,而 x 变量是栈所在数据的可变引用,所以,即使能够修改,修改的也是栈上的数据,对源变量也没有影响。

另外,还有一种场景的区别。

fn main() {
    let s = Some(String::from("hello"));

    // 如下这种方式意味着发生了所有权转移,后面的打印会报错
    // match s {
    //     Some(v) => println!("got value {}", v),
    //     None => {}
    // }

    // 意味着 match 只是借用了 s 变量,没有发生所有权转移
    match &s {
        Some(v) => println!("got value {}", v),
        None => {}
    }

    // 通过 ref 声明 v 是个指针,同样可以避免所有权转移
    match s {
        Some(ref v) => println!("got value {}", v),
        None => {}
    }
    println!("value is {}", s.unwrap());
}