前面几节攒下的 MaybeUninit<T>、Drop、ptr::read、ptr::write、set_len(),最后都会在 Vec 的实现里汇合。这篇就把它们对起来看。

还有一个我最初没想明白的问题:

如果一个类型占 0 字节,Vec<T> 还需要分配内存吗?如果不需要,它怎么表示自己拥有 100 个这样的元素?

参考:Vec<T>、Rustonomicon: Exotic Sizes。


一、先建立 Vec 的内存模型

let mut values: Vec<u32> = Vec::with_capacity(8);
values.push(10);
values.push(20);
values.push(30);
values.len()      // 3
values.capacity() // 至少 8

概念上的布局:

Vec<u32> 这个对象
┌─────────────────────┐
│ ptr                 │ ─────────────┐
│ len = 3             │              │
│ capacity >= 8       │              │
└─────────────────────┘              │
                                     ▼
堆内存
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ ?? │ ?? │ ?? │ ?? │ ?? │
└────┴────┴────┴────┴────┴────┴────┴────┘
  └─────────┘
    len = 3
  └───────────────────────────────────┘
               capacity >= 8

注意那些 ??。

这些位置属于分配出来的存储空间,但不一定包含已经初始化的 u32。这就是前几节反复强调的区别:

  • 容量描述可用存储空间;
  • 长度描述已经初始化、由容器拥有的元素。

Vec<T> 必须维护这个不变量:

0 ≤ len ≤ capacity

并且对普通的非零大小类型,[0, len) 中每个元素都必须是有效的 T。

这不是可有可无的约定,而是 Vec 能正确访问和析构元素的基础。

1.1 Vec 对象本身不等于元素数组

let values = vec![10u32, 20, 30];

values 这个变量保存的是容器的管理信息,元素通常在另一块单独分配的内存里。

Vec<T> 的管理对象
        ≠
Vec<T> 所拥有的元素存储空间

所以移动一个 Vec<T>,通常不需要把所有元素逐个复制到新地址——搬的只是那三个字的头。


二、RawVec:只管理内存,不负责元素生命周期

理解 Vec 的实现,可以从职责划分入手。概念上它分两层:

┌────────────────────────────┐
│ Vec<T>                     │
│                            │
│ len                        │
│ 元素初始化状态             │
│ 元素所有权与析构           │
└─────────────┬──────────────┘
              │
              ▼
┌────────────────────────────┐
│ RawVec<T>                  │
│                            │
│ 原始内存指针               │
│ 容量管理                   │
│ 分配与重新分配             │
│ 释放存储空间               │
└────────────────────────────┘

这是概念模型。具体字段和内部辅助类型会随 Rust 版本变化,别把这张图当成当前源码的逐字结构。

两层关心的东西不一样:

RawVec 只管:内存在哪、分配了多少容量、要不要扩容、怎么重新分配、怎么释放。

Vec 还要管:哪些位置已初始化、当前拥有几个元素、哪些要析构、怎么安全地增删和移动元素。

为什么要拆开?因为分配了一段内存,不代表里面已经存在对应数量的 T 对象。

let mut values: Vec<String> = Vec::with_capacity(100);

这行不会创建 100 个 String。它创建的是一个 len = 0、capacity >= 100 的容器。底层空间是有了,但没有 100 个 String 等着被析构。

这就是内存分配层和元素生命周期管理层必须协同工作的原因。


三、扩容时究竟发生了什么

let mut values = Vec::with_capacity(4);
values.push(10);
values.push(20);
values.push(30);
values.push(40);

此时 capacity = 4、len = 4。再 push(50),容器得腾出第 5 个位置:

原有内存
┌────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │
└────┴────┴────┴────┘
       capacity = 4
       len = 4
             │
             ▼
       申请更大空间
新内存
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ ?? │ ?? │ ?? │ ?? │
└────┴────┴────┴────┴────┴────┴────┴────┘
              capacity >= 5

这是逻辑结果,不意味着分配器一定会把数据搬到另一个地址。重新分配可能原地扩展,也可能整个搬走,取决于分配器、布局和实现策略。

如果要搬,原来的元素必须在新位置继续保持有效,并且不能在搬运过程中被重复析构。

3.1 Rust 的「移动」不一定逐字节复制

let a = String::from("hello");
let b = a;

执行后 a 不再拥有这个 String,所有权给了 b。

从语义上说这是一次移动。但对 String 这种类型,Rust 通常只要搬指针、长度、容量这几个字,不需要复制全部字符。

Vec 扩容时的情形又不一样:如果底层分配要移动,容器得保证原有元素被正确搬到新地址,而且对带析构函数的类型,不能留下两份都被认为有效的所有者。

旧地址:元素原本所在的位置
                │
                │ 受控地转移元素
                ▼
新地址:元素后续所在的位置

原来的位置不能再被当作仍然拥有该对象的有效元素。

真实的实现会按具体操作选不同策略,不能假定所有扩容都走同一套逐元素复制或移动算法。


四、为什么扩容用几何增长

如果每次加元素只把容量加 1,插 1000 个元素会怎样?

容量 0 → 1
容量 1 → 2
容量 2 → 3
...
容量 999 → 1000

每次扩容都要重新分配并搬移现有元素,总搬移量是:

1 + 2 + 3 + ... + (n-1) = n(n-1)/2

也就是 O(n²)。这对大量插入来说完全不能接受。

几何增长(比如按大于 1 的比例翻倍)就不同:

4 → 8 → 16 → 32 → 64 → ...

所有扩容中搬移的元素总数可以控制在 O(n)。于是连续 n 次 push() 的总成本是 O(n),单次 push() 的摊销复杂度是 O(1)。

要注意两点:

  • 摊销不等于每次都常数时间。触发扩容那一次仍然要搬一大批元素。
  • 具体倍率不是稳定 API 保证,会随标准库版本和实现变化。

真正该记住的是:几何增长在额外空间和重新分配成本之间取了不错的平衡。


五、重新分配失败怎么办

len = 4
capacity = 4

要给第 5 个元素腾地方,但新分配失败了,合理的容器行为是:

  • 原来的 4 个元素仍然有效;
  • 原来的所有权关系不能被破坏;
  • 不得析构未初始化的内存;
  • 不得释放仍由容器拥有的有效元素;
  • 失败不能让容器进入不一致状态。

这正好对应上一节的异常安全原则。

原有容器
    │
    ▼
申请新的存储空间
    │
    ├── 失败
    │     │
    │     ▼
    │  保留原有状态
    │
    └── 成功
          │
          ▼
      转移已有元素
          │
          ▼
      更新指针与容量
          │
          ▼
      释放旧存储空间

这是逻辑流程,不代表所有路径都严格这么分步。

还有个现实细节:某些分配器接口在失败时会直接触发分配错误处理,而不是返回 Result。所以别指望所有 Vec 扩容失败都像普通业务错误一样被 catch 住。

自己设计容器或 FFI 缓冲区时,就得明确回答:

  • 分配失败返回错误还是 panic?
  • 保留原有数据吗?
  • 允许部分完成吗?
  • 需不需要强异常保证?

这些都属于 API 设计的一部分。


六、最特殊的情况:零大小类型

前面聊的 u32、String 都占空间,但 Rust 允许定义零大小类型:

struct Marker;

struct Nothing {
    marker: (),
}

它们的大小都是 0:

use std::mem::size_of;

fn main() {
    println!("size_of::<()>() = {}", size_of::<()>());
    println!("size_of::<Marker>() = {}", size_of::<Marker>());
    println!("size_of::<Nothing>() = {}", size_of::<Nothing>());
}
size_of::<()>() = 0
size_of::<Marker>() = 0
size_of::<Nothing>() = 0

编译器不需要为每个值保留独立的有效载荷字节。

于是问题来了:

let mut values: Vec<Marker> = Vec::new();
values.push(Marker);
values.push(Marker);
values.push(Marker);
len = 3

三个 Marker 不占三个独立的元素存储区域,那容器怎么表示自己拥有三个元素?

答案:靠长度这类元数据表达元素数量,而不是靠元素占了多少字节。

len 表示元素数量,不是有效载荷字节数。这和前面建立的内存模型完全一致。


七、ZST 的 Vec 为什么不需要元素分配

use std::mem::size_of;

struct Marker;

fn main() {
    println!("Marker size = {}", size_of::<Marker>());

    let mut values = Vec::new();
    values.push(Marker);
    values.push(Marker);
    values.push(Marker);

    println!("len = {}", values.len());
    println!("capacity = {}", values.capacity());
}

注意这里的类型推导:因为后面插入了 Marker,编译器推出 Vec<Marker>。

标准库对 ZST 用了特殊路径。我在本机实测(rustc 1.99):

Marker size = 0
len = 3
capacity = 18446744073709551615

capacity 是 usize::MAX。这很合理:零大小元素不需要实际存储空间,容量上限就只剩 usize 能表示多少的问题。

不过 ZST 的具体内部表示属于实现细节,写代码时应该依赖 Vec 的公开 API,而不是去推断内部指针值。

7.1 ZST 不等于没有生命周期

这点特别容易误解。

use std::sync::atomic::{AtomicUsize, Ordering};

static DROPS: AtomicUsize = AtomicUsize::new(0);

struct Marker;

impl Drop for Marker {
    fn drop(&mut self) {
        DROPS.fetch_add(1, Ordering::SeqCst);
    }
}

Marker 不占有效载荷字节,但它有 Drop 行为:

let mut values = Vec::new();
values.push(Marker);
values.push(Marker);
values.push(Marker);
drop(values);

三个元素仍然要按容器的生命周期语义逐个析构。我实测打印了 3 次 drop Marker。

所以 ZST 的特殊性只在于:

  • 每个元素不需要独立的有效载荷存储;
  • 容器仍然要维护元素数量;
  • 元素仍然可能有析构行为;
  • 所有权与生命周期规则仍然生效。

零大小 ≠ 零生命周期,也不等于零析构成本。


八、ZST 为什么让指针运算变特殊

struct Marker;

因为 size_of::<Marker>() == 0,如果按普通数组的字节布局算地址:

第 0 个元素的偏移量 = 0 × 0 = 0
第 1 个元素的偏移量 = 1 × 0 = 0
第 2 个元素的偏移量 = 2 × 0 = 0

所有偏移量都是 0。

ptr.add(0)
ptr.add(1)
ptr.add(2)

对 ZST,不能期待这几个指针像普通数组那样对应不同字节地址。

但这不影响 Vec<Marker> 表示多个元素——Rust 的元素数量语义本来就不要求每个零大小值占据不同的字节。

同时要注意:不能把「元素大小为 0」理解成「指针运算可以随便做」。原始指针操作仍然必须满足 API 的安全契约。

8.1 一个常见的错误设计

假设自己写容器:

struct MyVec<T> {
    ptr: *mut T,
    len: usize,
    capacity: usize,
}

push 里用:

let destination = self.ptr.add(self.len);

对普通类型这符合直觉:索引对应不同字节偏移。但对 ZST:

size_of::<T>() == 0

按字节算的偏移量不随索引增长,这套逻辑就失效了。

容量管理也有问题:如果每个元素大小为 0,用「总分配字节数 ÷ 元素大小」来算容量会直接除零。

这就是标准库必须给 ZST 走特殊路径,而不能套用普通分配公式的原因。


九、ZST 与 Drop Check

把这一节和之前的 PhantomData<T> 接上:

use std::marker::PhantomData;

struct MyHandle<T> {
    raw: *mut u8,
    marker: PhantomData<T>,
}

PhantomData<T> 本身不占空间:

size_of::<PhantomData<String>>() == 0

但它仍然表达类型系统中的关系。即使不占内存,它也会影响型变和 auto trait 推导。

这里要分清两件事:

  • PhantomData<T> 能告诉编译器类型之间存在什么关系;
  • 它不能凭空证明原始指针有效,也不能保证外部资源还在。

如果 raw 指向 C 分配的对象,那么何时释放、谁负责释放、能否跨线程使用,仍然得由实现和安全契约保证。

这和 ZST 的核心思想一致:类型的内存大小,与它在类型系统中表达的语义,不是一个概念。


十、把 ZST、容量与 Drop 放一起

struct Marker;

impl Drop for Marker {
    fn drop(&mut self) {
        println!("drop Marker");
    }
}

fn main() {
    let mut values = Vec::new();
    for _ in 0..3 {
        values.push(Marker);
    }
    drop(values);
}

即使 Marker 大小为 0,容器仍然必须知道「我拥有三个元素」,否则没法正确析构。

所以 Vec 的核心不变量不能简化成:

len = 已经分配的字节数        ❌

正确的是:

len = 已初始化、由容器拥有的元素数量

普通类型下,元素存储空间大约是:

capacity × size_of::<T>()

但 ZST 时 size_of::<T>() = 0,这个乘法结果是 0,而不能据此推断容器没有任何逻辑元素。

长度、所有权、析构语义一样都不能少。


十一、一张表收尾

概念解决的问题关键注意
Vec<T>管理一组拥有的元素len 与 capacity 含义不同
RawVec<T> 这类原始存储管理管分配与容量分配内存 ≠ 初始化元素
扩容为更多元素腾空间可能原地扩展,也可能搬移
几何增长降低连续追加的摊销成本具体倍率不是稳定 API 保证
分配失败不破坏原有状态必须遵守具体分配 API 的失败语义
ZST表示不占有效载荷字节的类型仍有类型语义与生命周期
ZST 的 Vec<T>维护零大小元素的数量与所有权不能按普通分配模型推理
PhantomData<T>表达类型系统关系不会自动验证原始指针
Drop清理对象持有的资源ZST 也可能实现 Drop

十二、我拿这套东西检查什么

学完这些,我给自己列了几个固定的审查点。

1. 长度与初始化状态是否一致

unsafe {
    vec.set_len(new_len);
}

我会问:new_len 超过容量了吗?新纳入范围的元素真的初始化了吗?有没有控制流分支跳过初始化?有没有从外部函数返回、但初始化范围没被证明的情况?

2. 原始指针读取有没有破坏所有权

let value = ptr.read();

要确认:地址指向的值有效吗?这个位置是不是还被别的所有者管着?后面会不会再读一次或析构原对象?这次读取在正确的生命周期内吗?

3. 扩容期间对象状态

旧分配什么时候还有效?新分配何时接管元素?元素搬走后旧位置是否还被当作有效对象?中途失败哪些对象要清理?

4. ZST 有没有被特殊照顾

不能直接套用:

元素索引 × 元素大小 = 字节偏移量

然后假设每个元素都有独立存储区域。ZST 得单独处理指针运算、容量和元素生命周期。

这套检查的目的不是机械地给 unsafe 打标记,而是看代码有没有真正维护背后的不变量。


十三、三道自测题

题一:移动 Vec

let a = vec![String::from("hello")];
let b = a;

为什么这里通常不需要把字符串的字符重新复制一遍?同时为什么 a 不能再用了?

题二:ZST

struct Marker;

let mut values = Vec::new();
values.push(Marker);
values.push(Marker);
values.push(Marker);

Marker 大小为 0,为什么 values.len() 还是 3?如果 Marker 实现了 Drop,销毁这个容器时还要处理什么?

题三:扩容与异常安全

自己实现 Vec<T>。容量不足时已经申请了新内存,但只成功转移了部分元素就失败了。要维护哪些状态,才能保证:已转移的元素不被重复析构、未转移的元素不丢所有权、新旧内存不被错误释放、容器不会把未初始化位置当成有效元素?


下一篇写 Pin<T>:为什么某些对象一旦放到特定地址就不能再随意移动,以及这对异步 Future、FFI 回调和底层运行时意味着什么。