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 是一种特殊的引用, 它的值用于确定我们引用的集合范围. 这样可以非常高效地访问集合(否则我们可能会需要复制部分集合进行操作).