第3章 Rust 中的数据布局
低层编程非常关心数据布局,这是个大问题。它也无孔不入地影响着语言的其他部分,所以我们将从挖掘数据在 Rust 中的布局方式开始。
本章最好与《The Reference》中的类型布局部分保持一致,并使之成为仅仅是多渲染了一份。本书刚写的时候,《The Reference》已经完全失修,而 Rust 秘典试图作为《The Reference》的部分替代。现在的情况不再是这样了,所以这一整章最好可以删除。
我们会把这一章再保留一段时间,但理想的情况是,你应该把任何新的事实或改进贡献给《The Reference》。
repr(Rust)
首先,所有类型都有一个以字节为单位的对齐方式,一个类型的对齐方式指定了哪些地址可以用来存储该值。一个具有对齐方式n的值只能存储在n的倍数的地址上。所以对齐方式 2 意味着你必须存储在一个偶数地址,而 1 意味着你可以存储在任何地方。对齐至少是 1,而且总是 2 的幂。
基础类型通常按照其大小对齐,尽管这是特定平台的行为。例如,在 x86 上u64和f64通常被对齐到 4 字节(32 位)。
一个类型的大小必须始终是其对齐方式的倍数(零是任何对齐方式的有效大小),这就保证了该类型的数组总是可以通过偏移其大小的倍数来进行索引。注意,在动态大小的类型的情况下,一个类型的大小和对齐方式可能不是静态的。
Rust 给你提供了以下方式来布置复合数据。
- structs (命名复合类型 named product types)
- tuples (匿名复合类型 anonymous product types)
- arrays (同质复合类型 homogeneous product types)
- enums (命名总和类型 —— 有标签的联合体 named sum types -- tagged unions)
- unions (无标签的联合体 untagged unions)
如果一个枚举的所有变体都没有相关联的数据,那么它就被称为无字段(field-less)。
默认情况下,复合结构的对齐方式等于其字段对齐方式的最大值。因此,Rust 会在必要时插入填充,以确保所有字段都正确对齐,并且整个类型的大小是其对齐的倍数。比如说:
#![allow(unused)] fn main() { struct A { a: u8, b: u32, c: u16, } }
将在目标上以 32 位对齐,将这些基本类型对齐到它们各自的大小。因此,整个结构的大小将是 32 位的倍数。它可能变成:
#![allow(unused)] fn main() { struct A { a: u8, _pad1: [u8; 3], // 需要和 `b` 内存对齐 b: u32, c: u16, _pad2: [u8; 2], // 让总体的大小是 4 的倍数 } }
或者:
#![allow(unused)] fn main() { struct A { b: u32, c: u16, a: u8, _pad: u8, } }
所有数据都如同C语言中的一样,直接存储在结构里。然而,除了数组(密集排列且有序)之外,数据的布局在默认情况下都不是确定的。给出以下两个结构的定义:
#![allow(unused)] fn main() { struct A { a: i32, b: u64, } struct B { a: i32, b: u64, } }
Rust 确实保证 A 的两个实例的数据布局完全相同。然而,Rust 目前并不保证 A 的实例与 B 的实例具有相同的字段排序或填充。
对于我们编写的 A 和 B 来说,这一点似乎是没有必要的,但是 Rust 的其他几个特性使得该语言有必要以复杂的方式来处理数据布局。
例如,考虑这个结构:
#![allow(unused)] fn main() { struct Foo<T, U> { count: u16, data1: T, data2: U, } }
现在考虑一下Foo<u32, u16>和Foo<u16, u32>的单态化的结果。如果 Rust 按照指定的顺序排列字段,我们希望它能对结构中的值进行填充以满足其对齐要求。因此,如果 Rust 不对字段重新排序,我们希望它能产生以下结果:
struct Foo<u16, u32> {
count: u16,
data1: u16,
data2: u32,
}
struct Foo<u32, u16> {
count: u16,
_pad1: u16,
data1: u32,
data2: u16,
_pad2: u16,
}
后一种情况很显然浪费了空间,更高效地利用空间要求不同的单体有不同的字段排序。
枚举使情况变得更加复杂,直观地说,一个枚举如下:
#![allow(unused)] fn main() { enum Foo { A(u32), B(u64), C(u8), } }
可能会被布局成:
#![allow(unused)] fn main() { struct FooRepr { data: u64, // 根据 tag 的不同,这一项可以为 u64,u32,或者 u8 tag: u8, // 0 = A,1 = B, 2 = C } }
事实上,这正是它的布局方式(根据tag的大小和位置来调整)。
然而,在一些情况下,这样的表述是低效的。这方面的典型案例是 Rust 的“空指针优化”:一个由单个外部单元变量(例如None)和一个(可能嵌套的)非空指针变量(例如Some(&T))组成的枚举,使得标签没有必要。空指针可以安全地被解释为单位(None)的变体。这导致的结果是,size_of::<Option<&T>>() == size_of::<&T>()。
在 Rust 中,有许多类型会包含不可为空的指针,如Box<T>、Vec<T>、String、&T和&mut T。同理,我们可以想象嵌套的枚举将它们的标记集中到一个单一的字段中,因为根据定义,它们的有效值范围有限。原则上,枚举可以使用相当复杂的算法,在整个嵌套类型中用禁止使用的值来存储枚举类型。因此,我们不指定枚举布局是特别值得的。
非正常大小的类型
大多数的时候,我们期望类型在编译时能够有一个静态已知的非零大小,但这并不总是 Rust 的常态。
Dynamically Sized Types (DSTs)
Rust 支持动态大小的类型(DST):这些类型没有静态(编译时)已知的大小或者布局。从表面上看这有点离谱:Rust 必须知道一个东西的大小和布局,才能正确地进行处理。从这个角度上看,DST 不是一个普通的类型,因为它们没有编译时静态可知的大小,它们只能存在于一个指针之后。任何指向 DST 的指针都会变成一个包含了完善 DST 类型信息的胖指针(详情见下方)。
Rust 暴露了两种主要的 DST 类型:
Trait 对象代表某种类型,实现了它所指定的 Trait。确切的原始类型被删除,以利于运行时的反射,其中包含使用该类型的所有必要信息的 vtable。补全 Trait 对象指针所需的信息是 vtable 指针,被指向的对象的运行时的大小可以从 vtable 中动态地获取。
一个 slice 只是一些只读的连续存储——通常是一个数组或Vec。补全一个 slice 指针所需的信息只是它所指向的元素的数量,指针的运行时大小只是静态已知元素的大小乘以元素的数量。
结构实际上可以直接存储一个 DST 作为其最后一个字段,但这也会使它们自身成为一个 DST:
#![allow(unused)] fn main() { // 不能直接存储在栈上 struct MySuperSlice { info: u32, data: [u8], } }
不幸的是,如果这样的类型没有方法来构造它,那么它在很大程度上来看是没啥用的。目前,唯一支持的创建自定义 DST 的方法是使你的类型成为泛型,并执行非固定大小转换(unsizing coercion):
struct MySuperSliceable<T: ?Sized> { info: u32, data: T, } fn main() { let sized: MySuperSliceable<[u8; 8]> = MySuperSliceable { info: 17, data: [0; 8], }; let dynamic: &MySuperSliceable<[u8]> = &sized; // 输出:"17 [0, 0, 0, 0, 0, 0, 0, 0]" println!("{} {:?}", dynamic.info, &dynamic.data); }
(是的,自定义 DST 目前仅仅是一个基本半成品的功能。)
零大小类型 (ZSTs)
Rust 也允许类型指定他们不占空间:
#![allow(unused)] fn main() { struct Nothing; // 无字段意味着没有大小 // 所有字段都无大小意味着整个结构体无大小 struct LotsOfNothing { foo: Nothing, qux: (), // 空元组无大小 baz: [u8; 0], // 空数组无大小 } }
就其本身而言,零尺寸类型(ZSTs)由于显而易见的原因是相当无用的。然而,就像 Rust 中许多奇怪的布局选择一样,它们的潜力在通用语境中得以实现。在 Rust 中,任何产生或存储 ZST 的操作都可以被简化为无操作(no-op)。首先,存储它甚至没有意义——它不占用任何空间。另外,这种类型的值只有一个,所以任何加载它的操作都可以直接凭空产生它——这也是一个无操作(no-op),因为它不占用任何空间。
这方面最极端的例子之一是 Set 和 Map。给定一个Map<Key, Value>,通常可以实现一个Set<Key>,作为Map<Key, UselessJunk>的一个薄封装。在许多语言中,这将需要为无用的封装分配空间,并进行存储和加载无用封装的工作,然后将其丢弃。对于编译器来说,证明这一点是不必要的,是一个困难的分析。
然而在 Rust 中,我们可以直接说Set<Key> = Map<Key, ()>。现在 Rust 静态地知道每个加载和存储都是无用的,而且没有分配有任何大小。其结果是,单例化的代码基本上是 HashSet 的自定义实现,而没有 HashMap 要支持值所带来的开销。
安全的代码不需要担心 ZST,但是不安全的代码必须小心没有大小的类型的后果。特别是,指针偏移是无操作的,而分配器通常需要一个非零的大小。
请注意,对 ZST 的引用(包括空片),就像所有其他的引用一样,必须是非空的,并且适当地对齐。然而,通过空指针来加载或存储 ZST 类型的数据并不会导致未定义的行为,这与其他类型的指针不同。
空类型
Rust 还允许声明不能被实例化的类型。这些类型只能在类型层讨论,而不能在值层讨论。空类型可以通过指定一个没有变体的枚举来声明:
#![allow(unused)] fn main() { enum Void {} // 没有变体的类型 = 空类型 }
空类型甚至比 ZST 更加边缘化。空类型的主要作用是为了让某个类型不可达。例如,假设一个 API 需要在一般情况下返回一个结果,但一个特定的情况实际上是不可能的。实际上可以通过返回一个Result<T, Void>来在类型级别上传达这个信息。API 的消费者可以放心地 unwrap 这样一个结果,因为他们知道这个值在本质上不可能是Err,因为这需要提供一个Void类型的值。
原则上,Rust 可以基于这个事实做一些有趣的分析和优化,例如,Result<T, Void>只表示为T,因为Err的情况实际上并不存在(严格来说,这只是一种优化,并不保证,所以例如将一个转化为另一个仍然是 UB)。
下面的例子是可以编译的:
#![allow(unused)] fn main() { enum Void {} let res: Result<u32, Void> = Ok(0); // 不存在 Err 的情况,所以 Ok 实际上永远都能匹配成功 let Ok(num) = res; }
关于空类型的最后一个微妙的细节是,构造一个指向它们的原始指针实际上是有效的,但对它们的解引用是未定义行为,因为那是没有意义的。
我们建议不要用*const Void来模拟 C 的void*类型。很多人之前这样做,但很快就遇到了麻烦,因为 Rust 没有任何安全防护措施来防止用不安全的代码来实例化空类型,如果你这样做了,就是未定义行为。因为开发者有将原始指针转换为引用的习惯,而构造一个&Void也是未定义行为,所以这尤其成问题。
*const ()(或等价物)对void*来说效果相当好,可以做成引用而没有任何安全问题。它仍然不能阻止你试图读取或写入数值,但至少它可以编译成一个 no-op 而不是 UB。
外部类型
有一个已被接受的 RFC 来增加具有未知大小的适当类型,称为 extern 类型,这将让 Rust 开发人员更准确地模拟像 C 的void*和其他“声明但从未定义”的类型。然而,截至 Rust 2018,该功能在size_of_val::<MyExternType>()应该如何表现方面遇到了一些问题。
可选的数据布局
Rust 允许你指定不同于默认的数据布局策略。
repr(C)
这是最重要的 repr。它的意图非常简单:按照 C 的方式处理数据。字段的顺序、大小和对齐方式完全符合你对 C 或 C++ 的预期。该类型在 extern "C" 函数调用边界中的传递方式,也正如 C 传递相应类型时的方式一样。任何你期望通过 FFI 边界传递的类型都应该使用 repr(C),因为 C 是编程世界的通用语言。这样做对于安全地进行更复杂的数据布局技巧(例如将值重新解释为另一种类型)也是必要的。
我们强烈建议使用rust-bindgen和/或cbindgen来为你管理 FFI 的边界。Rust 团队与这些项目紧密合作,以确保它们能够稳健地工作,并与当前和未来关于类型布局和 reprs 的保证兼容。
必须记住repr(C)与 Rust 更奇特的数据布局功能的互动。由于它具有“用于 FFI”和“用于布局控制”的双重目的,repr(C)可以应用于那些如果通过 FFI 边界就会变得无意义或有问题的类型:
- ZST 仍然是零大小,尽管这不是 C 语言的标准行为,而且明确违背了 C++ 中空类型的行为,即它们仍然应该消耗一个字节的空间
- DST 指针(宽指针)和 tuple 在 C 语言中没有对应的概念,因此从来不是 FFI 安全的
- 带有字段的枚举在 C 或 C++ 中也没有对应的概念,但是类型的有效桥接是被定义的
- 如果
T是一个FFI 安全的非空指针类型,Option<T>被保证具有与T相同的布局和 ABI,因此也是 FFI 安全的。截至目前,这包括&、&mut和函数指针,所有这些都不能为空。 - 就
repr(C)而言,元组结构和结构一样,因为与结构的唯一区别是字段没有命名。 repr(C)相当于无字段枚举的repr(u*)之一(见下一节)。选择的大小和符号类型是目标平台的 C 应用二进制接口(ABI)的默认枚举大小与符号类型。请注意,C 语言中的枚举表示法是实现定义的,所以这实际上是一个“最佳猜测”。特别是,当对应的 C 代码在编译时带有某些标志时,这可能是不正确的。- 带有
repr(C)或repr(u*)的无字段枚举仍然不能在没有相应变量的情况下设置为整数值,尽管这在 C 或 C++ 中是允许的行为。如果(不安全地)构造一个枚举的实例,但不与它的一个变体相匹配,这是未定义的行为(这使得详尽的匹配可以继续被编写和编译为正常行为)。
repr(transparent)
#[repr(transparent)]只能用于只有单个非零大小字段(可能还有其他零大小字段)的结构或者单变体 enum 中。其效果是,整个结构的布局和 ABI 被保证与该字段相同。
注意:有一个叫做
transparent_unions的 nightly 的特性,可以让你对 union 指定repr(transparent)。不过由于设计上的一些顾虑,这个特性目前还未稳定,参考issue-60405。
我们的目标是使单一字段和结构/枚举之间的转换成为可能。一个例子是UnsafeCell,它可以被转换为它所包装的类型。(UnsafeCell也用了一个不稳定的特性no_niche,所以当它嵌套其它类型的时候,它的 ABI 也并没有一个稳定的保证。)
另外,当我们通过 FFI 传递结构/枚举,并且其中内部字段类型是另一端所需的类型时,我们能保证这是正确的。特别是,这对于struct Foo(f32)或者enum Foo { Bar(f32) }总是具有与f32相同的 ABI 是必要的。
只有在唯一的字段为pub或其内存布局在文档中所承诺的情况下,该 repr 才被视为一个类型的公共 ABI 的一部分。否则,该内存布局不应被其他 crate 所依赖。
repr(u*), repr(i*)
这些指定了使无字段枚举的大小和符号类型。如果判别符超过了它可以容纳的整数,就会产生一个编译时错误。你可以通过将溢出的元素明确设置为 0 来手动要求 Rust 允许这样做。
“无字段枚举”这一术语仅仅意味着该枚举的各个变体中不包含任何数据。没有使用 repr 的无字段枚举仍然是 Rust 的本地类型,其布局和表示并不稳定。添加 repr(u*) 或 repr(i*) 会使它在布局时完全被视作指定的整数类型(不过编译器仍会利用它对该类型中“无效”值的认识来优化枚举布局,比如当这个枚举被包裹在 Option 中时)。请注意,对于这些类型,函数调用的 ABI 通常仍未明确指定,除非在 extern "C" 调用中,它们与具有相同符号和大小的 C 枚举 ABI 兼容。
如果枚举有字段,其效果类似于repr(C)的效果,因为该类型有一个定义的布局。这使得将枚举传递给 C 代码或者访问该类型的原始表示并直接操作其标记和字段成为可能,详见RFC。
这些“repr”对结构(struct)没有作用。
在含有字段的枚举中加入明确的repr(u*)、repr(i*)或repr(C)可以抑制空指针优化,比如:
#![allow(unused)] fn main() { use std::mem::size_of; enum MyOption<T> { Some(T), None, } #[repr(u8)] enum MyReprOption<T> { Some(T), None, } assert_eq!(8, size_of::<MyOption<&u16>>()); assert_eq!(16, size_of::<MyReprOption<&u16>>()); }
空指针优化针对无字段且拥有repr(u*)、repr(i*)或repr(C)的枚举仍然生效。
repr(packed), repr(packed(n))
repr(packed(n))(其中 n 是 2 的幂)强制该类型的对齐要求最多为 n。最常见的用法是不显式指定 n,此时 repr(packed) 等同于 repr(packed(1)),它迫使 Rust 去除所有填充,仅将该类型对齐到一个字节。这可能会改善内存占用,但很可能带来其他负面副作用。
特别是,大多数体系结构强烈偏好数据自然对齐。这可能意味着未对齐的加载会受到惩罚(例如在 x86 架构上),甚至可能导致异常(某些 ARM 芯片)。对于直接加载或存储打包字段这类简单情况,编译器可能能够通过位移和掩码来掩盖对齐问题;然而,如果你对一个打包字段取引用,编译器很可能无法生成避免未对齐加载的代码。
由于这可能导致未定义行为,相关的 lint 已经被实现,并且将成为一个硬错误。
repr(packed)/repr(packed(n)) 不应轻易使用。除非你有极端的需求,否则不应采用这种方式。
这种 repr 是对 repr(C) 和 repr(Rust) 的一种修饰。如果你希望一个类型是 FFI 兼容的,那么你通常需要显式指定为:repr(C, packed)。
repr(align(n))
repr(align(n))(其中n是 2 的幂)强制类型至少按照n对齐。
这可以实现一些技巧,比如确保数组中的相邻元素不会彼此共享同一个缓存行(这可能会加快某些类型的并发代码)。
这是repr(C)和repr(Rust)的一个修改版本,它与repr(packed)不兼容。