• 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),但这里并不是需要这三个引用的生命周期都完全相同,而是只需标记相同的情况下,输入的生命周期要大于等于输出的生命周期即可。

  • 相关阅读:
    第一章 操作系统引论
    程序流程结构
    IE网页cab插件无法安装
    Java面试题整理(题目内容非原创)
    linux下安装字体
    博客园使用Silence主题装饰
    编码问题总结
    JUnit 4.10 新功能简介——RuleChain 和TemporaryFolder的应用
    Usage and Idioms——Rules
    Usage and Idioms——Theories
  • 原文地址:https://www.cnblogs.com/lsgxeva/p/8543366.html
Copyright © 2020-2023  润新知