深入 Rust enum 的内存世界

原文:Peeking inside a Rust enum --- Amos Wenger / fasterthanli.me


一个"看似简单"的问题

在最近一次 Rust Q&A 直播中,有人提了一个看起来很简单的问题:

为什么 SmartStringSmolStr 这类"小字符串"类型的大小和 String 相同,但 SmallVec 这类"小向量"类型却比 Vec 更大?

这个问题表面上是在问两个库的设计,但要真正理解它,我们需要从 Rust enum 的内存模型讲起。这篇文章就是这条探索之路的完整记录。


SmartString 和 String,谁更省内存?

先来看让人困惑的现象。以 smartstring crate 为例:

rust 复制代码
use smartstring::{Compact, SmartString};
use std::mem::size_of_val;

fn main() {
    let smart = SmartString::<Compact>::from("hello world");
    dbg!(size_of_val(&smart));

    let stand = String::from("hello world");
    dbg!(size_of_val(&stand));
}
scss 复制代码
[src/main.rs:6] size_of_val(&smart) = 24
[src/main.rs:9] size_of_val(&stand) = 24

两者都是 24 字节。但这不是全部的故事------String 会把字符串内容存在堆上,而 SmartString 对于短字符串会直接存在栈上。如果计算总内存使用量:

rust 复制代码
let smart = SmartString::<Compact>::from("hello world");
dbg!(size_of_val(&smart));  // 24

let stand = String::from("hello world");
dbg!(size_of_val(&stand) + stand.capacity());  // 35
scss 复制代码
[src/main.rs:6] size_of_val(&smart) = 24
[src/main.rs:9] size_of_val(&stand) + stand.capacity() = 35

我们可以用打印指针地址的方式来验证这件事。在 Linux 64 位系统上,栈地址和堆地址在虚拟地址空间中相距甚远,因此可以通过观察元数据地址和内容地址是否相近来判断数据存在哪里:

rust 复制代码
use smartstring::{Compact, SmartString};

fn main() {
    let smart = SmartString::<Compact>::from("hello world");
    let smart_meta = &smart as *const _;
    let smart_data = &smart.as_bytes()[0] as *const _;
    dbg!((smart_meta, smart_data));

    let stand = String::from("hello world");
    let stand_meta = &stand as *const _;
    let stand_data = &stand.as_bytes()[0] as *const _;
    dbg!((stand_meta, stand_data));
}
scss 复制代码
[src/main.rs:7] (smart_meta, smart_data) = (
    0x00007ffce4cf4728,
    0x00007ffce4cf4729,   // 与元数据几乎相邻,说明在栈上
)
[src/main.rs:12] (stand_meta, stand_data) = (
    0x00007ffce4cf47f8,
    0x0000555f87686a60,   // 与元数据地址相差悬殊,说明在堆上
)

当然,SmartString 只有 24 字节可用,一旦字符串太长就装不下了,这时它也会切换到堆模式:

rust 复制代码
let input = "Turns out you can blame your tools *and* be a good craftsperson. Who knew?";

let smart = SmartString::<Compact>::from(input);
// 输出: (0x00007ffd460d0268, 0x0000555f4636ca30)  <- 都在堆上了

所以 SmartString 是一个"一个类型,两种行为"的魔法类型。这背后的秘密,就是 enum。


一个词,很多含义:从 C 的 enum 说起

如果你有 C/C++/Java/C# 背景,enum 在你脑中的形象大概是这样的:

c 复制代码
#include <stdio.h>

typedef enum Drink {
    Drink_Water,   // = 0
    Drink_Soda,    // = 1
    Drink_Juice,   // = 2
} Drink;

int main() {
    Drink dri = Drink_Soda;
    printf("dri = %d\n", dri);           // dri = 1
    printf("sizeof(dri) = %ld\n", sizeof(dri));   // sizeof(dri) = 4
    printf("sizeof(int) = %ld\n", sizeof(int));   // sizeof(int) = 4
}

C 的 enum 本质上就是一个整数类型,变体名称只是若干整数常量的别名,Drink_Water = 0Drink_Soda = 1,以此类推。

但 C enum 有两个让人头疼的问题。

第一,switch 会默认 fall-through,一旦漏写 break 就会出错:

c 复制代码
void print_drink(Drink dri) {
    switch (dri) {
        case Drink_Water:
            printf("It's water!\n");
        case Drink_Soda:
            printf("It's soda!\n");   // 漏写 break,Water 会继续执行到这里
        case Drink_Juice:
            printf("It's juice!\n");
    }
}

// print_drink(Drink_Soda) 的输出:
// It's soda!
// It's juice!

顺带一提,C# 的 switch 也有同样的问题,不过它会在编译期报错阻止你隐式 fall-through------你必须显式写出 fall-through 的意图。

第二,没有类型安全,任意整数都能传进去,编译器不管:

c 复制代码
int main() {
    print_drink(47);   // 编译通过,运行时静默无输出
}

程序没有崩溃,也没有输出。它就是不声不响地什么都没做,而你根本不知道哪里出了问题。


Rust 的 enum:穷尽、安全、紧凑

现在用 Rust 写同样的程序:

rust 复制代码
use std::mem::size_of_val;

enum Drink {
    Water,
    Soda,
    Juice,
}

fn main() {
    let dri = Drink::Water;
    dbg!(size_of_val(&dri));
    dbg!(dri as u32);
}
csharp 复制代码
warning: variant is never constructed: `Soda`
warning: variant is never constructed: `Juice`

[src/main.rs:11] size_of_val(&dri) = 1
[src/main.rs:12] dri as u32 = 0

立刻能发现几个区别:

  • 编译器会警告未使用的变体,这在 C 中是不会有的
  • 变体名称自带命名空间 ,不需要像 C 那样手动加 Drink_ 前缀
  • 大小只有 1 字节,而 C enum 固定是 4 字节

而且我们可以轻松实现 Debug trait:

rust 复制代码
#[derive(Debug)]
enum Drink {
    Water,
    Soda,
    Juice,
}

fn print_drink(dri: &Drink) {
    println!("{:?}", dri);
}
复制代码
Water
Juice
Soda

match 的穷尽性检查

Rust 用 match 而不是 switch,并且 match 会强制你处理所有变体。如果漏掉任何一个,编译器直接报错:

rust 复制代码
fn print_drink(dri: &Drink) {
    match dri {
        Drink::Water => println!("it's water!"),
        Drink::Soda  => println!("it's soda!"),
        // 漏掉了 Drink::Juice
    }
}
go 复制代码
error[E0004]: non-exhaustive patterns: `&Juice` not covered
  --> src/main.rs:15:11
   |
15 |       match dri {
   |             ^^^ pattern `&Juice` not covered

编译器会提示两种修法------要么加通配符,要么把所有情况都列出来:

rust 复制代码
// 方法一:通配符
fn print_drink(dri: &Drink) {
    match dri {
        Drink::Water => println!("it's water!"),
        Drink::Soda  => println!("it's soda!"),
        _ => println!("it's something else!"),
    }
}

// 方法二:显式列出所有变体
fn print_drink(dri: &Drink) {
    match dri {
        Drink::Water => println!("it's water!"),
        Drink::Soda  => println!("it's soda!"),
        Drink::Juice => println!("it's juice!"),
    }
}

小贴士:觉得写 match arms 很繁琐?rust-analyzer 有"Fill match arms"功能,可以自动生成所有分支代码。

此外,match 在 Rust 里是一个表达式,可以直接用于赋值:

rust 复制代码
fn print_drink(dri: &Drink) {
    let name = match dri {
        Drink::Water => "water",
        Drink::Soda  => "soda",
        Drink::Juice => "juice",
    };
    println!("it's {}!", name)
}

类型安全:不能随意转换

在 C 里,任意整数都能当 enum 用。在 Rust 里:

rust 复制代码
let val: Drink = 4 as Drink;   // 编译错误!
go 复制代码
error[E0605]: non-primitive cast: `i32` as `Drink`

这从根源上杜绝了无效值进入系统。当然,有时候我们确实需要从整数构造 enum(比如解析二进制格式),这时可以用 transmute------但必须放在 unsafe 块里:

rust 复制代码
let juice_from_binary_format = 2;
let val: Drink = unsafe { std::mem::transmute(juice_from_binary_format as u8) };

更好的实践是提供一个安全的接口:

rust 复制代码
use std::convert::{TryFrom, TryInto};

impl TryFrom<i32> for Drink {
    type Error = &'static str;

    fn try_from(x: i32) -> Result<Self, Self::Error> {
        match x {
            0..=2 => Ok(unsafe { std::mem::transmute(x as u8) }),
            _ => Err("invalid Drink value"),
        }
    }
}

fn main() {
    let val: Drink = 2_i32.try_into().unwrap();   // ok,是 Juice
    let bad: Drink = 4_i32.try_into().unwrap();   // panic!
}
rust 复制代码
it's juice!
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: "invalid Drink value"'

内存大小的自动适配

回到 enum 的大小:三个变体的 Drink 是 1 字节,但如果变体数超过 256 个(即超过 u8 的范围),Rust 会自动用 u16 来表示:

rust 复制代码
enum Drink {
    Variant0,
    Variant1,
    // ... 共 257 个变体
    Variant256,
}

dbg!(size_of::<Drink>());  // 2 字节!

这里的技术术语叫"表示(representation)"。Rust enum 是对某个整数类型的抽象。以三变体的 Drink 为例,底层是 u8(能表示 0..256),但概念上只有 0、1、2 是有效值。因此:

  • Drinku8:总能成功(as u8
  • u8Drink:是可能失败的操作(需要 TryFrom

这个"底层 u8 始终应该是 0、1 或 2"的约束,叫做不变量(invariant) 。打破不变量会导致代码不可靠(unsound)

rust 复制代码
// 强行破坏不变量
let d: Drink = unsafe { transmute(15_u8) };
dbg!(&d);
dbg!(d == Drink::Juice);
ini 复制代码
[src/main.rs:14] &d = Juice    // 看起来是 Juice
[src/main.rs:15] d == Drink::Juice = false    // 但比较又不相等!

这就是为什么在 Rust 里,打破不变量必须使用 unsafe 关键字,这是一个明确的信号,告诉代码审查者:"这里要格外小心。"

关于 unsafe 代码,它并不天然等于"坏代码"------有时候确实无法绕开。但好的做法是尽量缩小 unsafe 的范围,并为它包裹安全的外部接口。Rust 社区也有专门的 safe transmute 工作组 在研究如何进一步减少 unsafe 的使用场景。

信任模型大致如下:Rust 核心团队负责确保标准库中的 unsafe 代码正确无误,而在此之上的 safe Rust 代码则受到完整的语言安全保障。


Rust enum 的真正威力:携带数据的变体

上面讲的 Drink 只是最简单的 enum------仅有标签,没有数据。Rust enum 真正有趣的地方在于,每个变体可以携带不同形状的数据,这使它成为一种正宗的代数数据类型(Algebraic Data Type,ADT)。

rust 复制代码
enum UserID {
    Number(u64),
    Text(String),
}

UserID 是一个和类型(sum type) :一个 UserID 要么是 UserID::Number 变体,要么是 UserID::Text 变体,不能同时是两者。使用时,需要用 match 展开:

rust 复制代码
fn print_user_id(id: &UserID) {
    match id {
        UserID::Number(n) => println!("user id number {}", n),
        UserID::Text(s)   => println!("user id {}", s),
    }
}

fn main() {
    print_user_id(&UserID::Number(79));
    print_user_id(&UserID::Text("fh99a73gbh8".into()));
}
bash 复制代码
user id number 79
user id fh99a73gbh8

我们已经见过其他和类型了------Result<T, E> 就是一个 enum:

rust 复制代码
pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

用 C 来模拟带数据的 enum

为了理解内存布局,我们试着用 C 来实现同样的东西:

c 复制代码
#include <stdint.h>
#include <stdio.h>

enum UserIDKind {
    UserIDKind_Number,
    UserIDKind_Text,
};

struct UserID {
    enum UserIDKind kind;
    uint64_t number;
    char *text;
};

void print_user_id(struct UserID* id) {
    switch (id->kind) {
        case UserIDKind_Number:
            printf("user id number %lu\n", id->number);
            break;
        case UserIDKind_Text:
            printf("user id %s\n", id->text);
            break;
    }
}

这里必须有一个额外的 kind 字段来记录当前是哪个变体。这就是所谓的判别字段(discriminant),也叫"tag"(所以才有"tagged union"这个名字)。

这个 C 实现有个明显缺陷------它是漏洞百出的抽象(leaky abstraction) :没有任何机制阻止你把 kind 设为 Number,同时却只填了 text 字段。更危险的是,用 malloc 分配但没有初始化的内存,在优化构建下会读到程序其他地方的数据,成为潜在的信息泄露漏洞:

c 复制代码
// release 构建下,可能输出随机内存里的内容
struct UserID *woops = malloc(sizeof(struct UserID));
woops->kind = UserIDKind_Text;
woops->number = 79;
print_user_id(woops);  // 输出: user id }  或  user id m  等随机内容

内存大小与 union 优化

C 的 struct UserID 有多大?

c 复制代码
printf("sizeof(struct UserID) = %ld\n", sizeof(struct UserID));
// sizeof(struct UserID) = 24

printf("%ld + %ld + %ld = %ld\n",
    sizeof(enum UserIDKind), sizeof(uint64_t), sizeof(char *),
    sizeof(enum UserIDKind) + sizeof(uint64_t) + sizeof(char *)
);
// 4 + 8 + 8 = 20

算出来是 20,但实际是 24------差距来自对齐填充(padding)kind(4字节)和 number(8字节)之间要插入 4 字节的填充,以保证 number 的起始地址是 8 的倍数。

现在来看 Rust 里同样的 enum:

rust 复制代码
use std::{mem::size_of, os::raw::c_char};

#[allow(dead_code)]
enum UserID {
    Number(u64),
    Text(*const c_char),
}

fn main() {
    dbg!(size_of::<UserID>());
}
arduino 复制代码
[src/main.rs:10] size_of::<UserID>() = 16

16 字节!比 C 的 24 字节更小。原因在于,Rust 的 enum 会把两个变体的数据区域重叠存放 (disjoint union)------u64 和指针永远不会同时有效,所以它们可以共用同一块内存。判别字段(discriminant)只占 1 字节,然后加上必要的对齐填充,总共 16 字节。

C 其实也有等效的写法------用 union 关键字:

c 复制代码
struct UserID {
    uint8_t kind;
    union {
        uint64_t number;
        char *text;
    };
};

// sizeof(struct UserID) = 16   与 Rust 结果相同!

甚至可以用 __attribute__((packed)) 彻底去掉填充:

c 复制代码
struct __attribute__((packed)) UserID {
    uint8_t kind;
    union {
        uint64_t number;
        char *text;
    };
};

// sizeof(struct UserID) = 9   只有 9 字节!

那 Rust 也可以这样做吗?

rust 复制代码
#[repr(packed)]
enum UserID {
    Number(u64),
    Text(*const c_char),
}
arduino 复制代码
error[E0517]: attribute should be applied to struct or union

不行。#[repr(packed)] 在 Rust 里只能用于 struct 和 union,不能用于 enum。这个限制已经被讨论过,但目前还没有实现。

这就带来了一个有趣的问题:如果 Rust 的 enum 不能被 pack,那 SmartString 是怎么做到和 String 一样大的?


亲手造一个 enum

既然 Rust 的 enum 不支持 pack,smartstring 的作者干脆自己实现了一个 enum。我们来跟着做一遍,看看会遇到什么问题。

思路是用一个 #[repr(packed)] 的 struct 来存储所有数据:

rust 复制代码
use std::mem::size_of;

#[repr(packed)]
struct SmartString {
    discriminant: u8,
    data: [u8; 24],
}

fn main() {
    dbg!(size_of::<SmartString>());  // 25 字节
}

好,25 字节,比 String 的 24 字节多 1 字节,这是目前能做到的极限。

现在要存储两种变体:

  • 堆模式(Boxed) :存一个 String(24字节)
  • 栈模式(Inline):存 utf-8 字节数组和长度

对于栈模式,最多只能存 24 字节的数据,所以长度也可以用 u8 表示:

rust 复制代码
struct Inline {
    len: u8,
    data: [u8; 23],
}

我们用 static_assertions crate 在编译期验证类型大小:

rust 复制代码
use static_assertions::*;

const VARIANT_SIZE: usize = std::mem::size_of::<String>();

#[repr(packed)]
struct SmartString {
    discriminant: u8,
    data: [u8; VARIANT_SIZE],
}

struct Inline {
    len: u8,
    data: [u8; VARIANT_SIZE - 1],
}

assert_eq_size!(String, Inline);   // 编译期断言:两者等大

构造方法

rust 复制代码
impl SmartString {
    pub fn new_boxed(s: String) -> Self {
        Self::new(0, s)
    }

    pub fn new_inline() -> Self {
        Self::new(1, Inline { len: 0, data: Default::default() })
    }

    fn new<T>(discriminant: u8, data: T) -> Self {
        let mut res = Self {
            discriminant,
            data: Default::default(),
        };
        let ptr: *mut T = res.data.as_mut_ptr().cast();
        unsafe { ptr.write_unaligned(data) };
        res
    }
}

读取数据

rust 复制代码
use std::mem::ManuallyDrop;

impl AsRef<str> for SmartString {
    fn as_ref(&self) -> &str {
        match self.discriminant {
            0 => {
                let s: *const ManuallyDrop<String> = self.data.as_ptr().cast();
                let tmp = unsafe { s.read_unaligned() };
                unsafe { &*(tmp.as_ref() as *const str) }
            }
            1 => {
                let s: *const Inline = self.data.as_ptr().cast();
                unsafe {
                    let slice = std::slice::from_raw_parts(
                        (*s).data.as_ptr(),
                        (*s).len as _,
                    );
                    std::str::from_utf8_unchecked(slice)
                }
            }
            _ => unreachable!(),
        }
    }
}

再实现 DisplayDebug

rust 复制代码
use std::fmt;

impl fmt::Display for SmartString {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s: &str = self.as_ref();
        fmt::Display::fmt(s, f)
    }
}

impl fmt::Debug for SmartString {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s: &str = self.as_ref();
        fmt::Debug::fmt(s, f)
    }
}

fn main() {
    let boxed = SmartString::new_boxed("This is a longer string, would not fit inline".into());
    let inline = SmartString::new_inline();
    dbg!(boxed, inline);
}
ini 复制代码
[src/main.rs:84] boxed = "This is a longer string, would not fit inline"
[src/main.rs:84] inline = ""

内存泄漏!

看起来能工作,但有一个致命问题------内存泄漏。我们用 Valgrind 验证:

ini 复制代码
$ cargo build --quiet --release && valgrind --tool=memcheck ./target/release/enumpeek

# 标准 String 的结果:
==173592== All heap blocks were freed -- no leaks are possible
==173592== ERROR SUMMARY: 0 errors

# 我们的 SmartString 的结果:
==173779== LEAK SUMMARY:
==173779==    definitely lost: 22 bytes in 1 blocks
==173779== ERROR SUMMARY: 0 errors

泄漏了!原因是:我们把 String 的字节存进了 [u8; 24],编译器并不知道这里存了一个 String,所以析构时不会自动调用 String 的 drop 方法,堆上的字符串数据就泄漏了。

我们需要手动实现 Drop

第一次尝试(失败)

rust 复制代码
impl Drop for SmartString {
    fn drop(&mut self) {
        match self.discriminant {
            0 => {
                let s: *mut String = self.data.as_mut_ptr().cast();
                let b: String = unsafe { *s };   // 编译错误!不能从裸指针移出
                drop(b);
            }
            // ...
        }
    }
}

第二次尝试(更糟糕)

rust 复制代码
let b = unsafe { Box::from_raw(s) };
drop(b);
arduino 复制代码
free(): invalid pointer
[1] abort (core dumped)

Valgrind 告诉我们问题所在:程序试图释放一个栈上的地址。String 结构体本身是在栈上的,只有它内部持有的字符串数据才在堆上。Box::from_raw 会尝试释放整个 String 结构体的地址,但那根本不是堆分配的!

正确做法 :用 std::ptr::read_unalignedString 从字节数组里"读"出来,让它正常析构:

rust 复制代码
impl SmartString {
    fn drop_variant<T>(&self) {
        unsafe { std::ptr::read_unaligned(self.data.as_ptr().cast::<T>()) };
        // read_unaligned 返回的值会在离开作用域时自动 drop
    }
}

impl Drop for SmartString {
    fn drop(&mut self) {
        match self.discriminant {
            0 => unsafe { self.drop_variant::<String>() },
            1 => unsafe { self.drop_variant::<Inline>() },
            _ => unreachable!(),
        }
    }
}

再跑 Valgrind:

ini 复制代码
==181085== All heap blocks were freed -- no leaks are possible
==181085== ERROR SUMMARY: 0 errors

内存问题解决了。


正视现实:我们的实现比 SmartString 大 1 字节

我们的 SmartString 是 25 字节,标准 SmartString 是 24 字节。就差这 1 个字节,和 SmallVecVec 多出来的那些字节是同一个原因:

rust 复制代码
use smallvec::SmallVec;

dbg!(size_of::<Vec<u8>>());              // 24
dbg!(size_of::<SmallVec<[u8; 1]>>());   // 32

SmallVec 也是一个带有显式判别字段的 enum,而 Rust enum 的对齐规则让这个判别字段膨胀到了 8 字节。

所以真正的谜题是:SmartString 是怎么做到不需要额外的 1 字节判别字段的?

要回答这个问题,我们需要深入了解指针。


深入了解指针

指针本质上就是一个数字,记录着某个内存地址。

**对齐(alignment)**是指针的关键属性:一个"对齐的"指针,其数值(地址)是所指向数据大小的整数倍。比如:

  • u8:对齐为 1,任何地址都对齐
  • u16:对齐为 2,地址必须是 2 的倍数
  • u32:对齐为 4,地址必须是 4 的倍数
  • u64:对齐为 8,地址必须是 8 的倍数

这就是 struct 里会有填充字节的根本原因------编译器要保证每个字段都处于合适的对齐地址上。

比如这样的 C struct:

c 复制代码
struct S {
    uint8_t a;
    uint16_t b;  // 需要 2 字节对齐
    uint8_t c;
    uint32_t d;  // 需要 4 字节对齐
};

实际布局是这样的(括号内为填充字节):

css 复制代码
[a] [pad] [b b] [c] [pad pad pad] [d d d d]
 0    1    2 3   4   5   6   7    8 9 10 11

共 12 字节,而不是 1+2+1+4=8 字节。


关键发现:指针的"不可能值"

在 64 位系统上,一个合法的内存指针是 8 字节宽(64 位)。理论上这允许表示 2^64 个不同的地址。

但实际上,x86-64 的当前规范中,内存地址只用了 48 位(或更少),高位字节中有大量的值组合在物理上永远不会出现 。更重要的是,有一个值绝对不可能是合法指针:0(null 指针)

Rust 的类型系统就利用了这个事实。

看这个例子:

rust 复制代码
use std::mem::size_of;

fn main() {
    dbg!(size_of::<Box<i32>>());          // 8 字节(一个指针)
    dbg!(size_of::<Option<Box<i32>>>());  // 也是 8 字节!
}

Option<Box<i32>> 应该比 Box<i32> 多出一个字节来存"是 Some 还是 None"的判别信息,但实际上两者完全等大。

原因:Box<i32> 内部是一个非空指针,它永远不可能是 null 。所以编译器就"借用"了 null 这个值来表示 None!不需要任何额外的空间。

这个技术叫做 Niche Optimization(槽位优化),或者更口语化地叫"Null 指针优化"。

我们可以用 NonNull 这个类型来直接表达"这个指针永不为空"的语义:

rust 复制代码
use std::ptr::NonNull;

fn main() {
    dbg!(size_of::<NonNull<i32>>());          // 8
    dbg!(size_of::<Option<NonNull<i32>>>());  // 也是 8!
}

同理,Option<&T>&T 的大小完全相同------因为合法的 Rust 引用永远不为 null,null 就成了 None 的编码。这在 FFI 场景中非常有用,Option<&T> 可以直接对应 C 里的可空指针。


SmartString 的真正秘密

现在我们有了理解 SmartString 的全部工具。

在"堆模式(Boxed)"下,SmartString 内部存的是一个 String,而 String 本质上包含一个非空的堆指针。

在"栈模式(Inline)"下,内容是直接存储的字节数据。SmartString 用最高位(MSB)来区分当前是哪种模式。当处于栈模式时,最后一个字节的最高位被设为 1;当处于堆模式时,堆指针所在的那个位置的最高位永远是 0(因为在 x86-64 的用户空间内,合法的堆地址最高位始终为 0)。

换句话说,SmartString 把判别信息嵌入到了数据本身的某一位里,而不是额外添加一个判别字段。这正是 Niche Optimization 的精髓。

SmallVec 就没有这么幸运了------它的"栈上数组([T; N])"和"堆上指针(*mut T)"之间,找不到这样一个可以借用的"不可能值"。因此 SmallVec 只能老老实实地保留一个显式的判别字段,导致它比 Vec 多出了对齐后的一整个字段大小(8 字节)。


总结

我们从一个简单的问题出发,走过了漫长的探索之旅。来做一个完整的梳理:

关于 Rust enum 的基础知识:

  • C enum 只是命名整数,缺乏类型安全和穷尽性检查
  • Rust enum 按变体数量自动选择最小整数表示(u8u16......),比 C enum 更紧凑
  • Rust 的 match 强制穷尽性,不会有 C switch 的 fall-through 问题
  • 不能把任意整数强转为 Rust enum,需要使用 TryFrom

关于带数据的 enum:

  • 每个变体可以携带不同类型、不同大小的数据
  • 内存布局等同于 C 的 tagged union:判别字段 + 最大变体的数据区
  • Rust enum 不支持 #[repr(packed)],所以对齐填充不可避免

关于 Niche Optimization:

  • 当某个类型有"不可能出现的值"(niche)时,编译器会借用它来存储 enum 的判别信息
  • 最典型的例子:非空指针的 null 值被用来表示 Option::None
  • 这让 Option<Box<T>>Option<&T> 与其内部类型等大,零额外开销

回答最初的问题:

  • SmartString 通过利用堆指针的最高位(一个 niche)来区分栈模式和堆模式,不需要额外的判别字段,所以与 String 等大(24 字节)
  • SmallVec 找不到可利用的 niche,只能显式保留判别字段,所以比 Vec 多出 8 字节(32 字节 vs 24 字节)

理解 Rust 的内存布局,不仅能帮助你写出更高效的代码,更能让你在阅读那些看似"不可思议"的库时豁然开朗。unsafe 代码固然复杂,但它也是 Rust 能在系统编程领域大展拳脚的基石------在安全与性能之间,Rust 找到了一条有原则的中间路。

参考原文:Peeking inside a Rust enum --- Amos Wenger, fasterthanli.me

相关推荐
fliter2 小时前
半小时读懂 Rust:从语法符号到所有权思维
后端
_Evan_Yao2 小时前
从“全量发布”到“小步快跑”:灰度发布的简单实践与学习路径
java·后端·学习
石小石Orz2 小时前
给Claude增加状态栏显示:claude-hud保姆级教程
前端·人工智能·后端
折哥的程序人生 · 物流技术专研2 小时前
《Java 100 天进阶之路》第21篇:Java Object类
java·开发语言·后端·面试·哈希算法
喵个咪3 小时前
Kratos + WebRTC 实战:实现浏览器 P2P 音视频通话与实时数据通信
后端·微服务·webrtc
Gopher_HBo3 小时前
GoFrameMap转换详解
后端
小江的记录本3 小时前
【MySQL】《MySQL日志面试背诵版+思维导图》(核心考点 + MySQL 8.0最新优化)
java·数据库·后端·python·sql·mysql·面试
yoyo_zzm3 小时前
PHP vs Java:后端语言终极选择指南
java·spring boot·后端·架构·php
苏三说技术3 小时前
从索引失效到性能翻倍,DBA不愿透露的10个优化技巧
后端