Rust 内存管理

1,738 阅读6分钟

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 前缀。它的意思是,借出的这个引用,是可以被借用者修改的。

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