Rust 入门 - 资源与生命周期

2,978 阅读11分钟

Rust 入门

可变性

在大多数的语言中存在常量变量的区分,在很多语言中常量表示栈不可变,但堆可变,例如:

final List<String> ss = Arrays.asList("hello");
ss = Arrays.asList("hello"); // 错误,栈不可变
ss.add("world");
System.out.println(ss) // [hello, world],ss 指向的对象改变了 堆可变

虽然在 java 中大部分时候开发者不用关心栈和堆的区别,但实际上“常量可变”这种特性在某些情况下任然会引发一些问题。而在 rust 中,常量就是常量,堆和栈都不可变,例如:

fn main(){
    let s = String::from("hello");
    s.push_str("workd");
    println!("{}", s);
}
/*
error[E0596]: cannot borrow `s` as mutable, as it is not declared as mutable
 --> hello.rs:3:5
  |
2 |     let s = String::from("hello");
  |         - help: consider changing this to be mutable: `mut s`
3 |     s.push_str("workd");
  |     ^^^^^^^^^^^^^^^^^^^ cannot borrow as mutable

error: aborting due to previous error
*/

可以看出当试图调用 s.push_str("workd") 编译器提示错误。也就是在 rust 中,声明的“变量”默认是堆和栈都不可以变的,除非显示指定为 mul:

fn main(){
    let mut s = String::from("hello ");
    s.push_str(" workd");
    println!("{}", s); // hello world
    s = String::from("the world");
    println!("{}", s); // the workd
}

实际上,用 let 声明的变量虽然不可变,但是可以重复声明,例如:

fn main(){
    let s = String::from("hello ");
    s = s.len() // error,不可以重复赋值
    s.push_str("workd"); // error,堆不可变
    let s = s.len(); // ok, 重复赋值
    println!("{}", s); // 6
}

所有权与声明周期

由于越来越多的语言开始附带 GC,开发者都可以不再关注资源所有权的问题。但是在 c 语言或者 c++ 中,如何处理资源所有权是一个非常重要且困难的问题。大部分的 c/c++ 都会被内存泄露或者野指针(悬垂指针)所困扰,总结起来大部分是因为:

  • 我申请了资源,但是我没有释放(内存泄露)
  • 我申请了资源,我释放了,但是别人不知道于是接着用这个不存在的资源(悬垂指针)

由于资源使用的问题实在太过复杂,后续的语言基本都通过资源回收的方式俩解决。开发者不用关心资源的回收问题,而是通过语言本身自带的 GC 来分析哪些资源需要释放并释放这些资源。GC 虽然解决了开发者管理资源的问题,但是无论是分析还是释放都存在一定的性能损耗。rust 则采用了另一种方式来处理这些问题。在 rust 中定义了非常明确的声明周期与所有权的概念,确保在编写的过程中能够解决 c/c++ 中的资源管理问题。

资源移动

在 rust 中所有的资源都有且只有一个所有者,在 rust 中声明的变量都不是资源本身,而是对资源的一种引用,注意这里所指的引用只是一个概念,并不是指在 rust 中的引用变量:

fn main(){
   let s1 = String::from("hello world");
   let s2 = s1; // value moved here
   println!("{}", s1); // ^^ value borrowed here after move
}

从错误提示可以看出,s1 所拥有的资源 move(移动) 给了 s2,它已经没有自己资源,因此不能再使用。可以看出 rust 中对资源的管理是非常严格的,绝不允许两个拥有者出现。这对于多线程编程非常有利。在有了资源移动这个行为后,我们就必须重新审视平时在其他语言中一些平常的概念,这里用伪代码表示:

s = 资源
call_function(s) // 如果调用了一个方法,那么这个资源会被移动吗

function(){
    s = 资源
    return s
}
outer_s = function() // 当函数返回一个资源,这个资源会被移动吗

many_s = [资源1,资源2]
one_s = many_s[0] // 从集合中拿出一个资源,那么这个资源会转移吗

one_s = 资源
many_s.append(one_s) // 将一个资源放入集合中,那么这个资源会转移吗

str1 = "hello" 
str2 = "world"
str3 = str1 + str2 // 拼接字符串之后,原始字符串还存在吗

我们先将分别分析这些场景在 rust 中是如何实现的。

引用与借用

首先在 rust 中,调用函数时传递参数也会转移资源:

fn main(){
    let s1 = String::from("hello world");
    give_me_your_resource(s1); // -- value moved here
    println!("{}", s1); // value borrowed here after move
   
}

fn give_me_your_resource(s: String){
    println!("length of {} is {}", s, s.len());
}

即如果一个变量传递给某个函数,则该变量的资源会被转移,你不能在调用函数之后访问该变量。如果调用一个函数,这个函数返回了某个持有资源的变量,则该资源会被转移:

fn give_you_resource() -> String{
    let s = String::from("hello world");
    s
}

fn main(){
    let s1 = give_you_resource();
    println!("{}", s1); // hello world
}

但是这明显有很多不方便的地方。例如编写了一个打印字符串长度的方法,在这个方法调用后对应的变量就变得不可用。当然,可以重复接受该资源继续使用:

fn main(){
    let s1 = give_you_resource();
    let s2 = say_length(s1); // length of hello world is 11
    println!("{} is still avaliable", s2); // hello world is still avaliable
}

fn say_length(s: String) -> String{
    println!("length of {} is {}", s, s.len());
    s
}

这明显是一种非常不优雅的行为,因此在 rust 中提出了引用的概念。引用不持有资源,他只是访问这个资源的媒介:

fn main(){
    let s1 = String::from("hello world");
    let s2 = &s1; // no move happend
    println!("{} is still avaliable", s1); // hello world is still avaliable
}

在 rust 中使用 & 表示创建一个引用。一个 T 类型的引用为 &T。因此这里的 s2 类型为 &String。可以看出,在将 s1 通过引用的方式赋值给 s2 后 s1 任然持有自己的资源。我们可以使用引用来优化一下我们的say_length 方法:

fn main(){
    let s1 = String::from("hello world");
    say_length(&s1); // no move happend
    println!("{} is still avaliable", s1); // hello world is still avaliable
}

这样我们就不至于重复接受资源。至于引用和非引用的区别生命周期的章节会详细分析。在 rust 中创建引用的过程称为借用。但是这里的借用和现实生活中的借用还存在一定差别。显示中 b 借用了 a 的资源,那么往往会发生资源移动。但是在 rust 中借用不存在真正的资源移动。引用可以是 mutable 但前提是该指向的变量需要声明为 mut,同时该引用也必须为 mut:

    let s1 = String::from("hello world");
    let s2 = & mut s1; // cannot borrow as mutable
    
    let mut s1 = String::from("hello world");
    let s2 = & mut s1; // ok, can borrow from mutable 
    s2.push_str(" hello");
    println!("{}", s1);

rust 不允许两个 mutable 引用指向同一个变量:

let mut s1 = String::from("hello world");
let s2 = & mut s1; 
let s3 = & mut s1; 
s2.push_str("sss");
// cannot borrow `s1` as immutable because it is also borrowed as mutable

甚至不能同时拥有 mutable 以及 immutable 的引用。当然有一种情况比较特殊,如果一个 immutable 在 mutable 之前借用,且之后不再使用,这也是被允许的:

fn main(){
    let mut s1 = String::from("hello world");
    let s3 = & s1; // never used before mutable borrow

    let s2 = & mut s1;
    s2.push_str("sss");
    println!("{}", s1); // hello worldsss
}

切片类型

切片在很多语言中用来截取集合类型的一部分数据,例如在 python 中:

a = [1, 2, 3]
b = a[0: 2]
print(b) # [1, 2]

但是在 rust 对所有权有着非常严格的控制,因此当我们使用切片时,被切的那部分资源需要转移吗?在 rust 中切片有单独的类型,切片类型。还有一点是需要注意的,切片类型是无法直接使用,因为它代表了真实的资源。在 rust 中我们只能使用切片引用来使用切片:

fn main() {
    let s = vec![1, 2 ,3];
    let s1 = &s[0..2]; // 切片引用
    print!("{:?}", s1); // [1, 2]
}

对于 T 类型的切片类型为 [T],切片引用类型则为 &[T]

字符串与切片

rust 中的字符串可能是初学者最容易搞混淆的地方,存在有这几种类型的 string:

let s = "hello"; // &str
let s1 = String::from("hello"); // String
let s3 = s1[0..3]; // &str
let s2 = &s1; // &String

首先使用字面量直接赋值或者对字符串进行切片的的类型是字符串切片类型。这个和一般的集合类型不同。 使用 String::from 方式创建的类型是直接持有字符串资源的类型,可以对字符串进行修改。同时还存在对字符串取引用的类型 &String。一般来说,&str 在 rust 中使用最广,因为它不涉及资源的移动。对于 String 类型的变量可以通过:

    let s = String::from("hello");
    let s1 = &s[..];
    let s2 = s.as_str();

转换为 &str。由于生命周期的严格控制,在 rust 中也没有像其他语言中的字符串插值方法。可以通过以下方法进行拼接:

// 定义一个 String 类型,使用 push_str
    let mut s = String::from("hello");
    {
        let s1 = String::from("world");
        s.push_str(s1.as_str()); // 注意这里拼接的时候实际上对 s1 进行了拷贝,所以不用担心 s1 生命周期到期的问题
        // 或
        // s.push_str("world")
    }
    print!("{}", s); 
// 使用 format! 宏
let s2 = format!("{} {}", "hello", "world");

// 使用 + 号连接,注意第二个参数需要是 &str。这个动作实际上是拷贝第二个参数,并合并到第一个参数,第二个参数所有权不会发生变化。但是第一个参数的所有权会转移。
let s3 = String::from("hello");
let s4 = s3 + " " + "world"; // 注意这里 s3 所有权已经转交给 s4 了,
print!("{}", s3); // error 

// 或者原地转移到 s3 中
let mut s3 = String::from("hello");
s3 = s3 + " " + "world";
print!("{}", s3);

生命周期

销毁时机

如果说资源移动管理了 rust 中资源的所有权,那么生命周就管理着 rust 中资源的释放时机。在所有的编程语言中只要资源被创建出来,那么必定有被回收的那一刻。不然随着程序的运行将会占用越来越多的内存。在 java 中当离开作用域时,变量将会被清除,但是所持有的资源不一定会立即清除,需要等到接下来的 GC 例如:

{
    var list = Arrays.asList("hello");
}
// 到这里 list 被清除,但是持有的 list 对象可能仍然存在,需要等待垃圾回收

当然,在 java 中一个资源可能被多个引用所持有,那么这个资源需要等到所有的引用都被清除后才会清除。在上面已经提到在 rust 中一个资源只能存在一个拥有者。并且一旦当这个拥有者被清除,对应的资源也会被清除

{
    let s = String::from("hello");
}
// 到这里 s 已经被清除了,其拥有的字符串资源也会被清除

对于这种生命周期管理方式,一旦涉及到引用事情就变得麻烦起来,因为我们很可能指向一个被销毁的资源:

fn main() {
    let s_ref;
    {
        let s = String::from("hello");
        s_ref = &s;
    }
    // 到这里, s 离开作用域被清除,相同的其对应的资源也被清除
    println!("{}", s_ref); // 危险!! 这里的 s_ref 引用了一个被清除的资源!!
}

幸运的是,rust 已经在编译期就帮我们解决了这个问题。当你试图运行代码时会发现: s does not live long enough。在你编写代码时,rust 就试图分析 s 以及 s_ref 的声明周期范围:

fn main() {
    let s_ref;                                   ——————————————————————                                                                                       |
    {                                                                 |
        let s = String::from("hello");   ----------                   | 
                                                  |                   |
                                                  s 生命周期范围        s_ref 生命周期范围
        s_ref = &s;                      _________|                   | 
    }                                                                 | 
    println!("{}", s_ref);                       ——————————————————————
}

可以看出 s 生命周期的范围明显小于 s_ref, rust 不会允许一个生命周期较长的引用指向一个声明周期较短的资源,因为这会出现类似悬垂指针问题即引用一个被清除的资源。当然相反则可以:

fn main() {
    let s = String::from("hello");             
    {                                                                                   
           s_ref = &s;    
           println!("{}", s_ref);    
    }                                                                       
}

此时 s 的声明周期较长,s_ref 的生命周期较短可以编译成功。还有一点需要注意的是,不要在函数中返回一个引用:

fn returen_ref() -> &String {
    let s = String::from("hello");
    return &s;
}

这明显是行不通的,无法通过编译。

泛型生命周期

当涉及到某个引用可能与多个资源有关时事情就变得更加复杂,例如假设我们想实现一个返回较长字符串的函数:

fn longest(s1: &String, s2: &String) -> &String{
    if s1.len() > s2.len(){
        s1
    }
    s2
}

请注意,这段代码暂时在 rust 中是无法编译,提示 expected named lifetime parameter。从理论上来讲,返回的引用的声明周期要短于 s1 以及 s2 中较短的那一个。但是函数未调用时,rust 不可能知道传入的参数生命周期是什么样的,更不可能知道 s1 以及 s2 中较短的那一个。因此 rust 引入了泛生命周期的概念,当我们编写一个函数时,必须使用生命周期标准来标注参数的声明周期,例如:

fn main() {
    let s1 = &String::from("sss");
    let s2 = &String::from("sss1");
    let s = longest(s1, s2);
    print!("{}", s)
}

fn longest<'a>(s1: &'a String, s2: &'a String) -> &'a String{
    if s1.len() > s2.len(){
        return s1;
    }
    s2
}

<'a> 表示该函数拥有泛型的生命周期,而 s1: &'a String, s2: &'a String 以及 &'a String 都使用 'a 标注,表示它们声明周期相同。当传入的 s1 s2 生命周期不同时,'a 表示它们重叠的那部分生命周期,例如:

fn main() {
    
    let s2 = &String::from("sss1");
    let s;
    {
        let s1 = &String::from("sss");
        s = longest(s1, s2);
    }
    print!("{}", s)
}

这无法通过编译,因为 s 的生命周期大于 s1 以及 s2 中较短的那部分。