jpfennell.com > posts > 2025-04-07

Rust 编译器中一个令人惊讶的 enum 大小优化

2025 年 4 月 7 日

Enum 是 Rust 中最受欢迎的特性之一。Enum 是一种类型,其值是指定的一组变体之一。

/// Foo 要么是一个 32 位整数,要么是一个字符。
enum Foo {
  Int(u32),
  Char(char),
}

Foo 类型的值可以是整数(例如,带有有效载荷 3 的变体 Foo::Int(3)),也可以是字符(例如,带有有效载荷 'A' 的变体 Foo::Char('A'))。如果你认为 struct 是其字段的 and 组合,那么 enum 就是其变体的 or 组合。

这篇文章是关于 Rust 编译器对 enum 值的内存表示执行的一个令人惊讶的优化,目的是使其内存使用量更小(剧透:它不是 niche 优化)。一般来说,保持值较小可以提高程序的运行速度,因为值可以在 CPU 寄存器中传递,并且更多的值可以放入单个 CPU 缓存行中。

通常,enum 的大小是最大有效载荷的大小,再加上一些额外的字节,用于存储指定值的变体的 tag。对于上面的 Foo 类型,两个变体的有效载荷都占用 4 个字节,并且我们至少需要一个额外的字节作为 tag。一个名为“类型对齐”(我不会深入讨论)的要求要求 Rust 实际上使用 4 个字节作为 tag。因此,该类型的总大小为 8 个字节:

assert_eq!(std::mem::size_of::<Foo>(), 8);

(所有代码片段都可以在 Rust Playground 中运行。)

在本文的其余部分,实际查看各种 enum 值的内存表示将非常有用且有趣。这是一个打印任何 Rust 值的原始字节表示的函数:

/// 打印类型 T 的值的内存表示。
fn print_memory_representation<T: std::fmt::Debug>(t: T) {
  print!("type={} value={t:?}: ", std::any::type_name::<T>());
  let start = &t as *const _ as *const u8;
  for i in 0..std::mem::size_of::<T>() {
    print!("{:02x} ", unsafe {*start.offset(i as isize)});
  }
  println!();
}

(此函数改编自这篇 10 年前的 Reddit 帖子。)

让我们为我们的 enum Foo 运行它。

print_memory_representation(Foo::Int(5));
// type=Foo value=Int(5): 00 00 00 00 05 00 00 00
//            |-- tag --| |- value -|
print_memory_representation(Foo::Char('A'));
// type=Foo value=Char('A'): 01 00 00 00 41 00 00 00 
//              |-- tag --| |- value -|

首先要指出的是,这台计算机的内存是 little endian,因此最低有效字节排在最前面。在 32 位十六进制中,数字 5 是 0x00000005,但其 little endian 表示形式是 05 00 00 00

考虑到这一点,我们看到前 4 个字节是 tag。整数变体已分配 tag 0,字符变体已分配 tag 1。后 4 个字节只是有效载荷的通常值。请注意,小写字母“a”在 ASCII 中是 41,这就是其内存表示形式为 41 00 00 00 的原因。

niche 优化

除了通用的 tags 方案外,还有一种众所周知的 enum 大小优化,称为 niche 优化。这种优化适用于只有其中一个变体具有有效载荷的类型。一个很好的例子是内置的 option 类型:

enum Option<char> {
  None,
  Some(char),
}

根据上一节中的 tags 分析,我们可能会猜测 enum 大小将为 8 个字节(最大的 4 字节有效载荷加上 4 个字节的 tag)。但实际上,这种类型的值总共只使用 4 个字节的内存:

assert_eq!(std::mem::size_of::<Option<char>>(), 4);

发生了什么?Rust 编译器知道,虽然 char 占用 4 个字节的内存,但并非这 4 个字节的每个值都是 char 的有效值。Char 只有大约 2^21 个有效值(每个 Unicode 代码点一个),而 4 个字节支持 2^32 个不同的值。编译器选择其中一种无效的位模式作为 niche。然后,它表示 enum 值而不使用 tags。它以与 char 相同的方式表示 Some 变体。它使用 niche 表示 None 变体。

一个有趣的问题是:Rust 到底使用什么 niche?让我们打印内存表示形式来看看:

let a: char = 'A'
print_memory_representation(a);
// type=char value='A': 41 00 00 00 
print_memory_representation(Some(a));
// type=Option<char> value=Some('A'): 41 00 00 00 
let none: Option<char> = None;
print_memory_representation(none);
// type=Option<char> value=None: 00 00 11 00 

正如我们所看到的,'A'Some('A') 的内存表示形式是相同的。Rust 使用 32 位数字 0x00110000 表示 None。快速搜索显示,这个数字正好比最大的有效 Unicode 代码点大 1。

超越 niche 优化?

我之前的理解是 Rust 不会执行任何更多的优化,所以最近当我发现一个优化时,我感到非常惊喜。

上下文是嵌套的 enum。从一个内部 enum 开始

enum Inner {
  A(u32),
  B(u32),
}

如果我们查看内存中的表示形式,它正如我们所预期的那样:8 个字节,其中前 4 个字节存储 tag,后 4 个字节存储有效载荷。

assert_eq!(std::mem::size_of::<Inner>(), 8);
print_memory_representation(Inner::A(2));
// type=Inner value=A(2): 00 00 00 00 02 00 00 00 
//            |-- tag --| |- value -|
print_memory_representation(Inner::B(3));
// type=Inner value=B(3): 01 00 00 00 03 00 00 00 
//            |-- tag --| |- value -|

现在添加另一个包含 Inner enum 作为有效载荷的 enum:

enum Outer {
  C(u32),
  D(Inner),
}

我的猜测是这种类型的值的大小为 12 个字节 - Inner 的 8 个字节(最大有效载荷),加上 tag 的 4 个字节。但事实并非如此 - 值仅占用 8 个字节!

assert_eq!(std::mem::size_of::<Outer>(), 8);

这里发生了什么?

首先,让我们检查一下 Outer::C 类型的值在内存中的样子:

print_memory_representation(Outer::C(5));
// type=Outer value=C(5): 02 00 00 00 05 00 00 00
//            |-- tag --| |- value -|

我们已经看到了一些奇怪的事情:Rust 选择使用 tag 数字 2 作为 Outer::C,而不是像对 Inner::A 那样从 0 开始。接下来看看 Outer::D

print_memory_representation(Outer::D(Inner::A(2)));
// type=Outer value=D(A(2)): 00 00 00 00 02 00 00 00 
//              |-- tag --| |- value -|
print_memory_representation(Outer::D(Inner::B(3)));
// type=Outer value=D(B(3)): 01 00 00 00 03 00 00 00 
//              |-- tag --| |- value -|

Outer::D(inner) 值的表示形式与 inner 的表示形式相同!

我猜 Rust 编译器已经将以下部分放在一起:

因此,我们可以将 Outer 的值表示为 <Outer tag><Outer remainder> 的形式,其中