Skip to main content

C4 Rust 核心概念 Ownership

Ownership 是 Rust 最为独特的概念, 深刻影响到 Rust 其他特性. 正是 Ownership 保证了 Rust 在没有内存回收器的情况下内存安全.

本章主要讲 Ownership, borrow, slice, 以及 Rust 数据在内存中如何布局.

什么是 Ownership​

Ownership 是一系列 Rust 用于管理内存的规则. 是在没有内存回收器的情况下, Rust 中进行内存管理的核心机制. 其原理是通过一系列由编译器检查的规则来管理内存, 若任何规则被违反, 则无法通过编译. 且这些规则不会导致程序运行速度受到任何影响.

首先需要建立两个认知:

  1. 在 Stack 上的数据内存访问速度比 Heap 快(因 Heap 访问会跟随指针而非直接访问).
  2. 在 Stack 上的数据内存分配速度比 Heap 快(因 Heap 分配首先需要查找可用空间).

而 Ownership 管理的主要就是 Heap/Stack 上分配的内存, 下面来看具体规则.

Ownership 规则​

共三条:

  • Rust 中每个值都有一个 Owner
  • 每个值同一时刻只有一个 Owner
  • 当 owner 走出作用域后, 这个值会被自动 drop 掉

上述规则需要挨个看看且明确一些基础概念.

变量作用域​

栈上内存地址通过一个变量名和其绑定, 而这个变量既可以是普通变量, 也可以是指针.

堆上内存地址必定最终是通过一个指针指向, 而这个指针可以是在栈上保存也可以在堆上保存, 但堆上保存的指针也必然最终会回溯到一个栈上存放的指针, 而这个指针值存放的栈上内存地址, 也必然有一个和其绑定的变量.

因此, 最终核心问题落脚到的仍然是和栈上内存地址绑定的变量作用域, 变量出作用域后, Rust 会自动将和其绑定的值 drop, 即释放. 比如下面的例子:

// 块或函数作用域
{
let slice = "hello"; // 硬编码的 &str 值实际存放在数据段中.
let y = 3;
// ...
} // slice 或 y 在此处后, 就会被 drop.

上述即栈上的值对应变量作用域的一个典型示例. 在栈上存放的值, 需要在编译时就能确认其 size.

而有些可能无法在编译时确定大小(基本数据类型值除外)的数据类型值, 需要在运行时堆上分配内存. 典型的是 String 类型.

堆上内存分配: String 类型​

比如下方代码:

{
let mut s = String::form("Hello");
s.push_str(", world!"); // 在堆上的值追加字符串.
} // s 出作用域后被释放

执行这行代码后, Rust 会在堆上分配一个内存, 存放 "Hello", 且我们定义了 s 可以被修改.

我们来明确一下 let mut s = String::from 这里的 s 到底是个啥?

  1. String 是一个结构体, 内部包含一个 Vec<u8>, 即 String 类型实际的物理存储为一个字节数组.
  2. Vec<u8> 内部包含的最终数据是一个指向某个值的 *const T 指针.
  3. 而 s 实际就是绑定到栈上一个结构体的内存起始地址, 该起始地址开始的区域(结构体)包含了 String 类型信息和指向其值的指针信息.
  4. 访问 s 时, 即可通过该结构体的类型信息和字符串数组指针, 找到该值并正确解释其内容(字符串).

出作用域后值就会被 drop 的话, 那如何保证函数传参或返回时候的值可用呢? 这就需要引出 Ownership 的 move 机制.

Ownership Move​

在如下场景下, 可能发生 Ownership 的 move:

  1. 将绑定变量进行传参操作(将变量 move 进闭包可认为此种情况)
  2. 将绑定变量进行返回操作(将变量从闭包 move 出也认为是此情况)
  3. 将绑定变量进行赋值操作

move 的意思是将值的 Ownership 转移给另外一个绑定变量, 这个机制用于确保 "同一时刻只有一个 Owner" 规则下值的传递能顺利进行.

但需要注意, 针对实现了 Copy 的类型, 这些类型值在栈上存放(如基本数据类型), 不会进行 move, 而是直接复制.

针对堆上值的 move, 来看一个简单的例子:

let s1 = String::from("Hello");
let s2 = s1; // 此处进行 Ownership move

当赋值操作时, 值里面的指针仍然指向的是同一个内存(堆上字符串值), 这样 s1 就出作用域但 ownership 在 s2 上, 堆上字符串就仍不会被释放.

还有一个问题要考虑: 若想在函数传参后继续使用原变量, 就不能进行 move(或只能 move 进去, move 出来, 再使用...), 显然是不行的, 此时需要使用 Rust 的另外一个核心特性: Reference & Borrow.

引用和借用​

基本概念如下:

  • 引用指对某被引用变量的"指针"创建便捷方式. (引用变量所绑定的内存中, 存放被引用变量的指针, 而非值的指针).
    • 这样可以在不违反 Ownership 规则下, 对原有值进行访问.
  • 对原有变量的创建引用动作, 称为 Borrowing.
  • 可以通过解引用操作 *, 对被 & 引用的原变量进行访问, 后面再详细看.
  • 可变引用: 可以对可变变量创建可变引用 &mut.
    • 这样就可以理解 let mut s = &mut s1; 了: s 自己是可变的(可以引用其他人), 也可以对被引用的其他人进行修改 &mut. 如下所示:
      let mut s = String::from("hello");
      let mut s1: &mut String = &mut s; // 可变的(能够再次引用其他人的)的, 且可以在引用期间改变其他人(可变引用)的引用 s1
      s1.push_str("haha"); // 此时 s 值为 "hellohaha"
      let mut s2 = String::from("world");
      s1 = &mut s2; // 再次引用其他的字符串 s2. 若把 s1 前面的 mut 去掉, 则这句话会报错.
      s1.push_str("wide"); // 此时 s2 的值为 "worldwide"

为避免数据出现竞争条件, 以及避免出现野指针, 创建引用有如下原则:

  1. 同一时刻对于同一变量: 可创建多个不可变引用, 或可创建一个可变引用(在可变引用存续期间, 不能再次创建不可变引用, 即可变引用和不可变引用无法共存.)
  2. 引用必须是有效的(不能出现野指针, 野指针典型情况是在函数中分配了一个堆上值, 但返回的是该值绑定变量的引用, 此时函数返回后值就被释放了)

特殊引用: slice​

我们可以对数组或字符串创建 slice, slice 是一种特殊的引用, 它的值用于确定我们引用的集合范围. 这样可以非常高效地访问集合(否则我们可能会需要复制部分集合进行操作).