/ 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)就和某个变量的生命周期绑定在了一起。在变量的作用域结束时,堆上的内存也被回收,因此我们就不需要在代码中来手动回收 Cvalue 字段的内存了。在例子中,只要出了函数 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 的生命周期类似,也是从声明开始到函数体结束,不过 ba 晚一些声明,因此它的生命周期开始的也就晚一些。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 是否还指向一个还存在的内存地址,完全取决于 s1s2 对应的长度了。在这个例子中,我们是写死的字符串,但真实世界中,这两个字符串可能来自于用户输入,所以只能在运行时才能确定是哪个比较长。这就是上面说的生命周期二意性。

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

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

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

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

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