Android程序员初学Rust-内存管理

166 阅读7分钟

1.jpg

通常,程序通过两种方式分配内存:

  • :用于存储局部变量的连续内存区域。

    • 在编译时就已知值的固定大小。
    • 速度极快:只需移动栈指针。
    • 易于管理:遵循函数调用。
    • 内存局部性良好。
  • :用于存储函数调用之外的值。

    • 值的大小在运行时动态确定。
    • 速度比栈稍慢:需要进行一些簿记操作。
    • 无法保证内存局部性。

创建一个字符串时,会将固定大小的元数据放在栈上,而将动态大小的数据(即实际的字符串)放在堆上:

fn main() {
    let s1 = String::from("Hello");
}

上述代码的内存布局大概是这样:

2.png

RustString 是基于 Vec 的,因此它有容量和长度,并且如果是可变的(mut String),可通过在堆上重新分配内存来增长其大小。

传统上,编程语言大致分为两大类:

  • 通过手动内存管理实现完全控制:CC++Pascal 等。
    • 程序员决定何时分配或释放堆内存。
    • 程序员必须确定指针是否仍指向有效的内存。
    • 研究表明,程序员容易犯错。
  • 通过运行时自动内存管理实现完全安全:JavaPythonGoHaskell 等。
    • 运行时系统确保在内存不再被引用之前不会被释放。
    • 通常通过引用计数或垃圾回收来实现。

Rust 则使用了一种全新的结合方式:

  • 通过在编译时强制内存管理的正确性,实现完全控制和安全性。

Rust 通过所有权来做到这一点。

C 语言必须使用 mallocfree 手动管理堆内存。常见错误包括忘记调用 free、对同一个指针多次调用 free,或者在指针所指向的内存被释放后再解引用该指针。

C++ 有诸如智能指针(unique_ptrshared_ptr)等工具,这些工具利用了语言在调用析构函数方面的保障机制,以确保函数返回时内存被释放。然而,仍然很容易误用这些工具,从而产生与 C 语言类似的错误。

JavaGoPython 依靠垃圾回收器来识别不再可达的内存并将其丢弃。这保证了任何指针都可以被解引用,从而消除了 “使用后释放” 以及其他类型的错误。但是,垃圾回收有运行时开销,并且很难进行合理优化。

在很多情况下,Rust 的所有权和借用模型能够实现与 C 语言相当的性能,仅在需要的地方进行分配和释放操作,即零开销。它还提供了类似于 C++ 智能指针的工具。必要时,也有诸如引用计数之类的其他选项可用,甚至有支持运行时垃圾回收的库。

以上仅仅讨论各种编程语言的内存管理特点,并非比较优劣

所有权 Ownership

3.jpg

所有变量绑定都有其有效的作用域,在变量作用域之外使用变量属于错误行为:

struct Point(i32, i32);

fn main() {
    {
        let p = Point(3, 4);
        dbg!(p.0);
    }
    // dbg!(p.1); // 编译错误:not found in this scope
}

我们说变量拥有那个值的所有权。在任何时刻,每个 Rust 值都有且仅有一个所有者。

在作用域结束时,变量会被释放,其数据也会被回收。此时可能会运行析构函数以释放资源。

你看,这就是 Rust 与其他语言不同的地方。无论是 Java 还是 C++,在编译期间是不会检查代码在内存管理上的缺陷的,例如检测 C++ 的多次 free ,或者 Java 的内存泄露。

部分读者可能知道垃圾收集器的实现——从一组 “根” 开始,找到所有可达的内存。Rust 的 “单一所有者” 原则也是类似的思路。

Move

4.jpg

赋值操作会在变量之间转移所有权:

fn main() {
    let s1 = String::from("Hello!");
    let s2 = s1;
    dbg!(s2);
    // dbg!(s1); // 编译错误:value used here after move
}
  • s1 赋值给 s2 会转移所有权。
  • s1 超出作用域时,不会发生任何事情:它不拥有任何东西。
  • s2 超出作用域时,字符串数据会被释放。

意思是说,当我们 let s2 = s1 的时候,s1 将不再拥有这个字符串的所有权,所有权会转移到 s2 上面,即 Move(移动)。

用几张示意图表示即可:

let s1 = String::from("Hello!");

5.png

let s2 = s1;

6.png

同样,当你将一个值传递给一个函数时,该值会被赋给函数参数。这也会转移所有权:

fn say_hello(name: String) {
    println!("Hello {name}")
}

fn main() {
    let name = String::from("Alice");
    say_hello(name);
    // say_hello(name); // 此时,name 的所有权已经在第一次 say_hello 时转移了
}

在第一次调用 say_hello 时,main 放弃了 name 的所有权。之后,namemain 函数中就不能再使用了。

name 分配的堆内存将在 say_hello 函数结束时释放。

如果 main 以引用的形式传递 name(即 &name),并且 say_hello 函数接受引用作为参数,那么 main 就可以保留 name 的所有权。

或者,main 可以在第一次调用时传递 name 的一个克隆(即 name.clone())。

Rust 默认使用移动语义,克隆操作需要程序员显式使用,因此与 C++ 相比,更难无意中创建副本。

当然对于简单的值类型,例如整数,浮点数,Rust 的处理情况有所不同。

Clone

7.jpg

有时你想要创建一个值的副本。Clone trait 就能实现这一点。

fn say_hello(name: String) {
    println!("Hello {name}")
}

fn main() {
    let name = String::from("Alice");
    say_hello(name.clone());
    say_hello(name); // Ok now.
}

// Output
// Hello Alice
// Hello Alice

通过克隆来通过检查器的检查是很常见的做法,之后再尝试优化掉这些克隆操作。

一般来说,clone 会对值执行深拷贝,也就是说,例如克隆一个数组,数组的所有元素也都会被克隆。

Copy

8.jpg

虽然移动语义是默认的,但某些类型默认会被复制:

fn main() {
    let x = 42;
    let y = x;
    dbg!(x); // 如果不是 `Copy` 类型将无法访问(因为所有权转移给了 y)
    dbg!(y);
}

这些类型实现了 Copy 特性,所有此处是复制,而不是移动所有权。

你可以让自己的类型选择使用复制语义:

#[derive(Copy, Clone, Debug)]
struct Point(i32, i32);

fn main() {
    let p1 = Point(3, 4);
    let p2 = p1;
    println!("p1: {p1:?}");
    println!("p2: {p2:?}");
}

赋值之后,p1p2 都拥有自己的数据,他们的数据的值相等。

我们也可以使用 p1.clone() 来显式地复制数据。

需要注意的是,复制和克隆不是一回事:

  • 复制指的是对内存区域进行按位复制,不适用于任意对象。
  • 复制不允许自定义逻辑(这与 C++ 中的复制构造函数不同),即,你不能自己实现 copy trait
  • 克隆是一种更通用的操作,并且通过实现 Clone 特性也允许自定义行为。
  • 复制不适用于实现了 Drop 特性的类型。

Drop

drop.jpg

实现了 Drop 特性的值可以指定在其超出作用域时运行的代码:

struct Droppable {
    name: &'static str,
}

impl Drop for Droppable {
    fn drop(&mut self) {
        println!("Dropping {}", self.name);
    }
}

fn main() {
    let a = Droppable { name: "a" };
    {
        let b = Droppable { name: "b" };
        {
            let c = Droppable { name: "c" };
            let d = Droppable { name: "d" };
            println!("Exiting innermost block");
        }
        println!("Exiting next block");
    }
    drop(a);
    println!("Exiting main");
}

输入如下:

// Output
Exiting innermost block
Dropping d
Dropping c
Exiting next block
Dropping b
Dropping a
Exiting main

值在超出作用域时会自动被丢弃。

当一个值被丢弃时,如果它实现了 std::ops::Drop,那么其 Drop::drop 实现会被调用。

无论其字段是否实现了 Drop,所有字段也都会被丢弃。