Comprehensive Rust 课程解读:Unions —— 需要手动追踪活跃字段的 unsafe 联合体
2026/9/10 17:07:19 网站建设 项目流程

Comprehensive Rust 课程解读:Unions —— 需要手动追踪活跃字段的 unsafe 联合体

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

Rust 的union(联合体)在表面上与枚举(enum)相似,但编译器不会替你追踪当前哪个字段处于活跃状态,读取任一字段都必须写在unsafe块中。本文以 Comprehensive Rust 课程 Unions 一节为核心,结合仓库中 Unsafe Rust 章节的源码与练习,深入讲解 union 的声明、访问、未定义行为风险、FFI 场景中的真实用途,以及std::mem::transmutezerocopy等更安全的字节重解释替代方案,帮助你判断"何时该用 union、何时不该用"。

Unions 是什么:与枚举的核心差异

根据课程术语表(src/glossary.md)的定义,union 是一种可以保存不同类型值、但同一时刻只能保存其中一种的数据类型。这一点听起来与枚举很接近,但存在本质区别:

  • 枚举(enum):编译器记录并使用一个隐式的判别值(discriminant)来标记当前活跃的变体(variant),读取字段时不需要也不允许 unsafe;
  • 联合体(union):所有字段共享同一块内存,同一时刻只有最后一个被写入的字段是"活跃的",而编译器不会帮你记住它是谁。

这正是课程原文点明的核心论断:"Unions are like enums, but you need to track the active field yourself"(联合体像枚举,但你需要自己追踪活跃字段)。这也决定了访问 union 字段是 Unsafe Rust 提供的五项新增能力之一——在 Unsafe Rust 导览 中明确列出了这五类操作:解引用裸指针、访问或修改可变静态变量、访问union字段、调用unsafe函数(含extern函数)、实现unsafetrait。

最小可运行的 Union 示例

课程给出了一个可以直接运行的最小示例(来自 src/unsafe-rust/unions.md):

#[repr(C)] union MyUnion { i: u8, b: bool, } fn main() { let u = MyUnion { i: 42 }; println!("int: {}", unsafe { u.i }); println!("bool: {}", unsafe { u.b }); // Undefined behavior! }

这段代码有三个值得逐行拆解的要点:

  1. #[repr(C)]是必须的:union 与 C 语言联合体的内存布局对齐才能保证互操作语义。没有这个属性,Rust 编译器保留调整布局的自由,字段在内存中的偏移量无法作为稳定契约。这也是课程在 FFI 练习 中对dirent等 C 结构体同样使用#[repr(C)]的原因。
  2. 初始化只需要写一个字段MyUnion { i: 42 }只显式初始化i,此时b占用的正是同一块字节,其值完全取决于i写入的位模式。
  3. 读取字段必须用unsafe:读取u.iu.b都被unsafe包裹。前者此刻是安全正确的(因为i确实是活跃字段),后者则是未定义行为——见下节。

为什么读取字段必须unsafe:未定义行为的来源

Rust 中访问 union 字段被强制要求unsafe,是因为你正在绕过编译器的安全保证。课程原文在示例中以注释方式直接标注了println!("bool: {}", unsafe { u.b }); // Undefined behavior!

具体而言,这个示例构造了一个典型的 UB 场景:u中活跃字段是u8类型的i,其值为42(二进制0b0010_1010)。这个位模式对于bool类型来说并不是合法的取值——Rust 中bool必须是01,其他任何位模式都会使程序进入未定义行为。示例中这一行之所以是"演示用",正是为了说明:编译器无法替你验证"你读的字段恰好是活跃字段"这一前置条件

值得注意的是:即使位模式恰好合法(例如u.b对应字节为01),从 union 的非活跃字段读取依然属于未定义行为的范畴。课程在 Unsafe Rust 一节中定义得非常清楚:Safe Rust 保证内存安全、不可能产生未定义行为;而 Unsafe Rust 一旦前置条件(precondition)被破坏就可能触发未定义行为。访问 union 字段时,你的前置条件就是"所读字段确实是当前活跃字段",而这只能靠开发者自己维护。

安全注释:Unsafe 代码的纪律要求

课程在多个章节反复强调 Unsafe 代码的纪律,这对 union 的使用同样适用:

  • 在 Calling Unsafe Functions 一节中明确要求:每个unsafe块都必须附带一条安全注释(safety comment),解释为什么这段代码实际上仍然是安全的。缺失安全注释的 unsafe 代码被认为是不健全(unsound)的。
  • 在 Dereferencing Raw Pointers 一节进一步指出:为每个unsafe块写注释说明其如何满足安全要求,是良好实践,也是 Android Rust 风格指南的要求。

因此,即使你确信某个 union 字段当前活跃,正确的写法也应形如:

#[repr(C)] union MyUnion { i: u8, b: bool, } fn main() { let u = MyUnion { i: 42 }; // SAFETY: 我们刚刚初始化了 i 字段,因此 i 是活跃字段, // 读取它是安全的。 println!("int: {}", unsafe { u.i }); }

这种"隔离并封装 unsafe"的总体策略在 Unsafe Rust 一节也有体现:Unsafe 代码应当保持小而隔离、正确性需要仔细记录,并最终包在一层安全抽象之内。对应到 union,最理想的做法是把"哪个字段活跃"的状态封装进一个安全 API,让调用方永远接触不到裸unsafe块。

为什么 Rust 中很少需要 Union:枚举是更优选择

课程明确指出:"Unions are rarely needed in Rust as enums provide a superior alternative."(Rust 中很少需要 union,因为枚举提供了更优越的替代方案)。

原因在于:

  1. 枚举自带判别值:编译器隐式维护 discriminant,读写变体字段由语言本身保证正确,不存在"忘记追踪活跃字段"的问题;
  2. 内存开销通常可接受:在绝大多数场景中,枚举额外占用的判别值字节相对整体结构可以忽略;
  3. 模式匹配完备matchif let等模式匹配机制让枚举的使用既安全又直观,这是课程 Pattern Matching 等章节反复训练的能力。

因此,写 Rust 时的默认决策应该是:优先考虑枚举;只有当确实需要与 C 的联合体二进制布局对齐时,才考虑 union

Union 的真实用途:与 C 库 API 交互(FFI)

课程给出的唯一合理使用场景是:"They are occasionally needed for interacting with C library APIs."(偶尔需要用于与 C 库 API 交互)。这是 union 存在的根本理由——C 语言中联合体是常见语言特性,许多系统 API 的数据结构内嵌union,Rust 要直接与之对接就必须提供对应布局的类型。

课程在 Unsafe Rust 章节的 FFI 练习 中展示的dirent结构就是一个很好的旁证:为了匹配 Linux 与 macOS 上readdir(3)返回的 C 结构布局,练习代码用#[repr(C)]按字段偏移精确复刻了结构体。如果该 C 结构中含有联合体字段,Rust 侧就必须用union声明,并在读取时根据上下文(例如另一个字段记录的d_type标签值)自行判断活跃字段——这正是课程所说的"自己追踪活跃字段"在真实 FFI 中的样子。

使用模式一般可以归纳为:

  1. #[repr(C)]声明 union,字段类型与 C 侧完全对应;
  2. 通过unsafe extern "C"声明并调用 C 函数(参见 exercise.rs 中opendir/readdir/closedir的声明方式);
  3. 读取 union 字段前,依据 C API 契约(文档、其他字段、返回值)确认活跃字段;
  4. unsafe块中读取,并写上解释安全性的注释。

重新解释字节的替代方案:transmute 与 zerocopy

课程特别提醒了一个常见的需求误区:"如果只是想以另一种类型重新解释字节,你大概率需要的是std::mem::transmute,或zerocopy这类安全包装 crate,而不是 union。"

  • std::mem::transmute:标准库提供的底层字节重解释工具,用于在两个大小兼容的类型之间直接按位转换。它是标准库中典型的低阶 unsafe 函数,课程在 Calling Unsafe Functions 中的忠告同样适用:标准库存在大量底层 unsafe 函数,应当优先选择安全替代方案
  • zerocopycrate:课程在 zerocopy 一节中介绍了这个来自 Fuchsia 项目的 crate,它通过 trait(如ImmutableIntoBytes)和派生宏提供"字节序列与其他类型之间"的安全转换。仓库中的完整示例(src/bare-metal/useful-crates/zerocopy-example/src/main.rs)展示了其用法:
use zerocopy::{Immutable, IntoBytes}; #[repr(u32)] #[derive(Debug, Default, Immutable, IntoBytes)] enum RequestType { #[default] In = 0, Out = 1, Flush = 4, } #[repr(C)] #[derive(Debug, Default, Immutable, IntoBytes)] struct VirtioBlockRequest { request_type: RequestType, reserved: u32, sector: u64, } fn main() { let request = VirtioBlockRequest { request_type: RequestType::Flush, sector: 42, ..Default::default() }; assert_eq!( request.as_bytes(), &[4, 0, 0, 0, 0, 0, 0, 0, 42, 0, 0, 0, 0, 0, 0, 0] ); }

这个示例的意义在于:当你需要把数据结构(包括含枚举的字段)打包成字节序列交给外部接口(如 DMA、网络协议、硬件共享结构)时,zerocopy能通过派生宏在编译期验证类型安全性,并把as_bytes()这样的转换变成安全 API——完全不需要手写 union 再去手动保证活跃字段。课程同时提醒,zerocopy不适用于 MMIO 场景(因为它不使用 volatile 读写),但非常适合处理与硬件共享(如 DMA)或经外部接口传输的结构。

总结与决策准则

回到课程 Unions 一节的定位:它是 Comprehensive Rust 课程 Day 4 Afternoon 的 Unsafe Rust 大章中的一环,紧跟在裸指针解引用、可变静态变量之后,是"Unsafe Rust 五项能力"清单中的一项。结合本节内容,可以整理出以下实用的决策准则:

需求场景推荐方案
保存多种类型、只需记住当前是哪种枚举(enum),绝大多数情况下的首选
对接 C 库中内嵌union的数据结构#[repr(C)]的 Rustunion+unsafe读取,必须自行追踪活跃字段
单纯按位重解释字节std::mem::transmute(低阶)或zerocopy(推荐,提供安全包装)
结构体序列化给外部接口 / DMAzerocopy派生宏生成安全转换

最后再强调课程反复灌输的三条红线:访问 union 字段必须在unsafe块中读取非活跃字段(或位模式非法的字段)是未定义行为每个unsafe块都要写清楚"为什么这里安全"的注释。记住课程在 Unsafe Rust 的总体原则——Unsafe 代码应该小而隔离、正确性仔细记录、并用安全抽象层包裹——union 的使用自然会被限制在"确实需要与 C 布局对齐"的少数场景中。

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询