• Rust 内存管理


    Rust 内存管理

    Rust 与其他编程语言相比,最大的亮点就是引入了一套在编译期间,通过静态分析的方式,确定所有对象的作用域与生命周期,从而可以精确的在某个对象不再被使用时,将其销毁,并且不引入任何运行时复杂度。

    现代编程语言,对于堆上分配的内存(可以理解为 malloc 出来的内存)进行管理,不外乎两种方式:使用者在代码中显示调用函数,回收这部分内存;或者引入自动的垃圾回收机制,在运行时由程序自动管理。

    前者的问题是给代码编写者引入了额外的工作,并且很难避免出 bug。后者的问题是会降低程序性能,尤其是对实时性要求比较高的程序。

    值类型与引用类型

    现代编程语言,大部分都会把类型分成两种:值类型与引用类型。

    值类型一般类似 Java 中的 int / byte / bool 这种大小固定,分配在栈上的数据类型。在 Rust 中,这类类型都会实现 Copy 这个 trait,来标记它是一个值类型。

    另外一种是大小不固定/可变的引用类型,比如 Java 中的 String,这种数据类型在内存中实际上是两部分:一部分在堆上,内容是其实际数据,另外一部分分配在栈上,内容实际上是个内存地址,指向栈上的实际数据。

    对于值类型,因为它们保存在函数调用栈上,在函数调用结束,这个栈会被整体销毁,因此不存在「内存管理」这个问题。真正需要管理的,是引用类型的变量,因为在函数调用结束时,即使销毁了栈上保存的数据的地址,堆上的数据依然存在,这时不再做处理的话,就会发生内存泄漏。

    RAII

    RAII 全称为 Resource Acquisition Is Initialization,是 C++ 中的一种常见编程范式。RAII 也可以用作内存管理,参考如下代码:

    class C {
    public:
      int *value;
    
      C() {
        value = new int();
      }
    
      ~C() {
        delete value;
      }
    };
    
    void f() {
      auto c = C();
    }
    
    int main() {
      c();
      return 0;
    }
    

    在上述代码中,C 这个类的构造函数进行内存分配,析构函数进行内存回收,这样这个类对应的堆上的内存(这里是 value)就和某个变量的生命周期绑定在了一起。在变量的作用域结束时,堆上的内存也被回收,因此我们就不需要在代码中来手动回收 C 中 value 字段的内存了。在例子中,只要出了函数 fc.value 就会自动被回收。

    这种方式代码编写者不需要手动回收内存,并且代码运行时也没有额外的负担。

    Rust 的引用类型,都相当于已经应用了上面提到的 RAII 技术,在离开变量的生命周期作用域时,会自动将本身对应堆上的内存清空。

    不过 RAII 也有一些缺陷,比如将 c 赋值给另外一个变量上,会导致类的析构函数被调用两次,以及多线程等复杂的情况下的正确性。

    move 语义

    Rust 的赋值(= 语句)、函数传参、返回结果这三个操作,如果针对的目标是一个值类型的话,相当于把这个值的内容复制到目标上,原来的值上的修改不会应用到新的值上。这一点和其他常见编程语言相同。举个例子:

    fn main() {
        let a = 1;
        let mut b = a;
        b += 1;
        println("a: {}, b: {}", a, b);  // 输出为 "a: 1, b: 2",并且此时两个变量都可以被使用。
    }
    

    那在一个引用类型上,执行上述操作会如何呢?我们以 String 为例:

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

    此时我们会遇到一个编译错误:

    error[E0382]: use of moved value: `a`
     --> a.rs:4:20
      |
    3 |     let b = a;
      |         - value moved here
    4 |     println!("{}", a);
      |                    ^ value used here after move
      |
      = note: move occurs because `a` has type `std::string::String`, which does not implement the `Copy` trait
    

    原因是,这类引用类型,在进行赋值、函数传参、返回结果操作时,并不是把内存内容复制一份过去,而是将数据「移动」到了新的变量上,原来的变量会不能使用。

    这样就能确保堆上分配的一段内存,都只有唯一的拥有者。这样就解决了上面提到的 RAII 将一个引用类型变量赋值给另外一个类型,内存被回收两次的问题了。

    引用

    不过在 Rust 中,move 语义虽然保证了每个引用类型数据都有唯一的拥有者,但是这样也给编写代码造成了不便。比如我们想写一个计算 String 长度的函数:

    fn get_string_length(the_s: String) -> usize {
        return the_s.len();
    }
    
    fn main() {
        let s = String::from("Hello!");
        get_string_length(s);
        println!("{}'s length is {}", s, length);
    }
    

    编译时会得到一个错误:

    error[E0382]: use of moved value: `s`
     --> a.rs:8:35
      |
    7 |     let length = get_string_length(s);
      |                                    - value moved here
    8 |     println!("{}'s length is {}", s, length);
      |                                   ^ value used here after move
      |
      = note: move occurs because `s` has type `std::string::String`, which does not implement the `Copy` trait
    
    error: aborting due to previous error
    

    原因就是在调用 get_string_length 的时候,实际字符串的拥有权,已经从变量 s 转移到了 get_string_length 函数的参数 the_s 上,后续再使用 s 当然会失败。

    当然我们可以修改一下函数,让它在最后不仅返回字符串的长度,同时也返回作为参数的字符串,这样所有权又可以转移回调用者上。不过显然这种做法会很啰嗦并且不优雅。

    为此 Rust 又引入了 引用 这个概念。引用有些类似 C++ 中的引用,并且都是只需要在变量以及类型的前面加上 & 前缀即可。我们用引用来对上面的代码进行改写:

    fn get_string_length(s: &String) -> usize {
        return s.len();
    }
    
    fn main() {
        let s = String::from("Hello!");
        let length = get_string_length(&s);
        println!("{}'s length is {}", s, length);
    }
    

    这样代码就可以正确编译和运行了。

    在 Rust 中,通过引用,之前需要进行 move 语义的操作,就会变成 borrow 语义的操作,对象的生命周期并不会转移,只是暂时「借出」到了新的地方。

    引用的可变性

    如果学过 Rust,都应该知道在声明一个变量的时候,可以加上 mut 前缀,来表明这个变量是可以改变的。

    在声明一个引用的类型时,也可以加上 mut 前缀。它的意思是,借出的这个引用,是可以被借用者修改的。

    不过值得注意的是,一个变量只能借出一个可变引用,此时不能再借出任何引用(包括非可变引用)。这个限制是为了防止多线程情况下,数据的一致性出现问题。

    生命周期

    除了上述概念之外,关于 Rust 内存管理,还有一个生命周期(lifetime)的概念。

    生命周期指的是一个变量的作用域范围。理论上来说,不光 Rust,其他大部分常见编程语言都有生命周期这一概念,只不过只有在 Rust 中,生命周期才可以显示的声明。

    现有如下代码:

    fn lifetime_showcase() {
        let a = 1;
        let b = 2;
        {
            let c = 3;
        }
        // Other codes here ...
    }
    

    这段代码中,变量 a 就拥有一个生命周期,是从声明开始到这个函数体结束。b 的生命周期类似,也是从声明开始到函数体结束,不过 b 比 a 晚一些声明,因此它的生命周期开始的也就晚一些。c 是在一个单独的变量作用域范围内的变量,因此它的生命周期更短,只能在这个作用域范围内。

    然后假设我们有这么一个函数,接受两个 &str 作为参数,然后比价其长度,然后返回长度最大的那一个:

    fn longest(x: &str, y: &str) -> &str {
        if x.len() > y.len() {
            return x;
        }
        return y;
    }
    

    看上去没什么问题,然而我们实际运行的时候, Rust 编译器会给出如下错误:

    error[E0106]: missing lifetime specifier
     --> a.rs:1:33
      |
    1 | fn longest(x: &str, y: &str) -> &str {
      |                                 ^ expected lifetime parameter
      |
      = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
    
    error: aborting due to previous error
    

    这个错误是告诉我们,函数返回了一个引用类型的结果,然而我们不知道这个引用类型是参数 x 还是 y,因此就不能确定返回结果的生命周期。

    根本原因是,Rust 独特的「引用借出」概念,编译器要明确知道每个引用的所被使用的位置。然而这个函数中,我们接受了两个引用类型作为参数,然后在运行时决定将其中的一个引用类型返回,因此编译器在编译(这个函数)时并不能得知哪个引用被返回了。这样如果调用函数的两个参数拥有不同的生命周期,那么返回结果的生命周期也就不能确定了。

    我们设想如下调用 longest 函数的场景:

    fn main() {
        let s1 = String::from("foo");
        let result;
        {
            let s2 = String::from("barbaz");
            result = longest(s1.as_str(), s2.as_str());
        }
        println!("result is: {}", result);
    }
    

    这种情况下,result 这个函数返回结果显然比 s2 这个函数传入参数要长,因此这个函数调用完成后,除了函数体中的那个作用域范围,到达 println! 语句的时候,result是否还指向一个还存在的内存地址,完全取决于 s1 和 s2 对应的长度了。在这个例子中,我们是写死的字符串,但真实世界中,这两个字符串可能来自于用户输入,所以只能在运行时才能确定是哪个比较长。这就是上面说的生命周期二意性。

    在这个例子中,显然 longest 的函数需要一个隐含前提:「返回结果的生命周期,需要是输入参数的两个生命周期中,最大的那个」。这样就可以保证不管返回哪个参数,结果的生命周期条件都是可以满足的。

    然而遗憾的是,Rust 编译器不能根据上下文推断出这个隐含前提,所以就需要我们手动进行指定了。只要把 longest 的函数签名改为:

    fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { ... }
    

    代码就可以正确编译通过。这里引入了一个新的引用生命周期标记 'a,其实只要是有 ' 前缀的标记,都可以作为引用生命周期标记,后面的 a 是一种惯用法。

    这里指的注意的是,函数的两个输入参数,以及返回结果都有相同的引用生命周期标记('a),但这里并不是需要这三个引用的生命周期都完全相同,而是只需标记相同的情况下,输入的生命周期要大于等于输出的生命周期即可。

  • 相关阅读:
    支持向量机SVM知识点概括
    决策树知识点概括
    HDU 3081 Marriage Match II
    HDU 3572 Task Schedule
    HDU 4888 Redraw Beautiful Drawings
    Poj 2728 Desert King
    HDU 3926 Hand in Hand
    HDU 1598 find the most comfortable road
    HDU 4393 Throw nails
    POJ 1486 Sorting Slides
  • 原文地址:https://www.cnblogs.com/lsgxeva/p/8543366.html
Copyright © 2020-2023  润新知