Rust 零大小类型(ZST)
在 Rust 中,零大小类型(Zero-Sized Type,简称 ZST) 是指在内存中不占用任何存储空间的类型。这些类型的大小为 0 字节,编译器会对它们进行优化,避免为它们分配实际的存储空间。ZST 是 Rust 类型系统中一个非常重要的概念,通常用于标志性的用途(marker types)或类型级别的计算。
1. 零大小类型的定义
零大小类型的特点
- 没有任何数据字段:结构体、枚举或类型本身不包含任何数据。
- 大小为 0 字节:它们在内存中不占用实际的空间。
- 编译器优化:ZST 可以在编译时被优化掉,多个 ZST 的实例在内存中不会重复分配。
示例:ZST 的定义
空结构体
struct Empty;
fn main() {
let e1 = Empty;
let e2 = Empty;
println!("Size of Empty: {}", std::mem::size_of::<Empty>());
println!("e1 address: {:p}\ne2 address: {:p}", &e1, &e2);
println!("Are e1 and e2 the same? {}", std::ptr::eq(&e1, &e2));
}
输出:
Size of Empty: 0
e1 address: 0x7ffecbf97d3e
e2 address: 0x7ffecbf97d3f
Are e1 and e2 the same? false
空枚举
enum EmptyEnum {}
fn main() {
println!("Size of EmptyEnum: {}", std::mem::size_of::<EmptyEnum>()); // 输出 0
}
单元类型(()
)
fn main() {
let unit = ();
println!("Size of unit type: {}", std::mem::size_of::<()>()); // 输出 0
}
2. 为什么需要零大小类型?
ZST 的存在是因为 Rust 的类型系统要求所有类型都有一个明确的定义和行为,即使某些类型在运行时根本不需要实际的数据存储。
以下是 ZST 的一些常见用途:
(1) 标志类型(Marker Types)
ZST 可以作为标志性类型,用来描述某些行为或特性。例如,PhantomData
是一个 ZST,常用于表示类型中的占位符,尤其在泛型编程中。
use std::marker::PhantomData;
struct MyType<T> {
_marker: PhantomData<T>, // 不占用内存,只用于标记类型 T
}
fn main() {
let instance: MyType<u32> = MyType { _marker: PhantomData };
println!("Size of MyType<u32>: {}", std::mem::size_of::<MyType<u32>>()); // 输出 0
}
(2) 单元类型(()
)
单元类型是一个 ZST,用于表示无返回值的函数或某些操作的结果。例如:
fn do_nothing() {}
fn main() {
let result = do_nothing(); // `result` 的类型是 `()`
println!("Size of unit type: {}", std::mem::size_of::<()>()); // 输出 0
}
- 单元类型通常作为函数的默认返回类型。
()
表示“什么都没有”,但在类型系统中需要明确地表示这种空值。
(3) 用作占位符(Placeholder)
ZST 可以用来表示某些需要类型约束的情况下的占位符,而不需要实际的数据。例如,定义一个 ZST 来实现某些特征,而无需实际存储数据:
struct NoData;
impl NoData {
fn new() -> Self {
NoData
}
}
fn main() {
let x = NoData::new();
println!("Size of NoData: {}", std::mem::size_of::<NoData>()); // 输出 0
}
(4) 优化内存占用
Rust 编译器会对 ZST 进行优化,比如在容器中存储 ZST 时,编译器会避免为它分配额外的空间。
use std::alloc::{Layout, System};
struct Empty;
fn main() {
// 验证 Vec<Empty> 的堆分配
let vec_empty: Vec<Empty> = Vec::with_capacity(10);
let layout_empty = Layout::array::<Empty>(vec_empty.capacity()).unwrap();
println!("Vec<Empty> capacity: {}", vec_empty.capacity());
println!("Vec<Empty> heap allocation size: {}", layout_empty.size()); // 输出 0 字节
// 验证 Vec<i32> 的堆分配
let vec_i32: Vec<i32> = Vec::with_capacity(10);
let layout_i32 = Layout::array::<i32>(vec_i32.capacity()).unwrap();
println!("Vec<i32> capacity: {}", vec_i32.capacity());
println!("Vec<i32> heap allocation size: {}", layout_i32.size()); // 输出 40 字节 (10 * 4)
}
输出:
Vec<Empty> capacity: 18446744073709551615 # 0xFFFFFFFFFFFFFFFF
Vec<Empty> heap allocation size: 0
Vec<i32> capacity: 10
Vec<i32> heap allocation size: 40
- 这里的
Vec<Empty>
存储了 3 个 ZST 实例,但由于 ZST 的大小为 0,编译器优化掉了实际的存储。 Vec
本身仍然需要存储元信息(如容量、长度等),因此它占用固定的内存(通常为 24 字节)。
3. 如何检查类型的大小?
你可以使用 std::mem::size_of
函数来检查任何类型的大小:
use std::mem;
struct Empty;
struct Data {
x: i32,
y: i32,
}
fn main() {
println!("Size of Empty: {}", mem::size_of::<Empty>()); // 输出 0
println!("Size of Data: {}", mem::size_of::<Data>()); // 输出 8
println!("Size of (): {}", mem::size_of::<()>()); // 输出 0
}
4. 注意事项
虽然 ZST 的大小为 0,但在以下情况下需要注意:
(1) 指针的行为
即使是 ZST,引用它们的指针仍然占用内存(通常是一个机器字大小,比如 8 字节)。
struct Empty;
fn main() {
let e1 = Empty;
let e2 = Empty;
// 即使 ZST 的实例没有大小,但它们的引用是有效的
println!("Size of &Empty: {}", std::mem::size_of::<&Empty>()); // 输出 8(指针大小)
}
(2) 不能创建空枚举的实例
如果一个枚举没有任何变体,它的大小仍然是 0,但你无法创建它的实例:
#[derive(Debug)]
enum EmptyEnum {}
fn main() {
let x: EmptyEnum; // 可以声明
// let x: EmptyEnum = EmptyEnum; // 无法创建实例
println!("Size of EmptyEnum: {}", std::mem::size_of::<EmptyEnum>()); // 输出 0
}
5. 总结
- 零大小类型(ZST) 是一种占用 0 字节内存 的类型,常用于标志、单元类型、占位符等场景。
- 常见 ZST:
- 空结构体(
struct Empty;
) - 空枚举(
enum EmptyEnum {}
) - 单元类型(
()
) - 标志性类型(如
PhantomData
)
- 空结构体(
- 用途:
- 节省内存
- 编译时标志性用途
- 泛型占位符或类型约束
- 重要特性:
- 多个实例在内存中没有区别(指针地址可能相同)。
- 虽然值本身没有大小,但它们的引用(指针)仍然需要占用内存。
ZST 是 Rust 类型系统的一个独特设计,提供了高效和灵活的方式来表达类型信息,同时避免了多余的运行时开销。
例子
例 1
use std::ops::Deref;
struct Parent;
impl Parent {
fn say_hello(&self) {
println!("Hello from Parent");
}
}
struct Child;
impl Deref for Child {
type Target = Parent;
fn deref(&self) -> &Self::Target {
&Parent
}
}
fn main() {
let child = Child;
child.say_hello();
}
例 2
use std::ops::Deref;
struct Parent{
pub data: String,
}
impl Parent {
fn say_hello(&self) {
println!("Hello from Parent: {}", self.data);
}
fn update_data(&mut self, data: String) {
self.data = data;
}
}
struct Child {
pub inner: Parent,
}
impl Deref for Child {
type Target = Parent;
fn deref(&self) -> &Self::Target {
&Parent { data: "Initial Data".to_string() }
}
}
fn main() {
let child = Child;
child.say_hello();
}
第一个可以编译,第二个报错:
error[E0515]: cannot return reference to temporary value
--> src/main.rs:24:9
|
24 | &Parent { data: "Initial Data".to_string() }
| ^-------------------------------------------
| ||
| |temporary value created here
| returns a reference to data owned by the current function
分析
这里的核心区别在于值的生命周期以及它们是如何分配和存储的。在 Rust 中,静态值(static
lifetime)和临时值(temporary value)的区别,决定了它们的生命周期及能否被返回引用。
例 1 返回的 &Parent
是指向一个静态生命周期的值,因为 Parent
是一个全局的静态变量。换句话说,它是一个固定的内存地址,在程序整个运行期间始终有效。静态值满足 Rust 的生命周期规则,编译器能够确保这个引用是安全的。
例 2 试图返回一个引用,指向一个临时值(Parent { data: ... }
),而这个临时值在函数结束后会被释放。Rust 的生命周期检查器会阻止这种行为,因为返回的引用会变得无效,可能导致悬垂引用(Dangling Reference)。
为什么 &Parent
是静态值,而 &Parent { data: "Initial Data".to_string() }
不是?
1. Parent
是一个零大小类型(ZST)
在例 1 中,Parent
只是一个没有任何字段的结构体:
struct Parent;
因为它没有字段,所以它占用的内存大小为 0 字节(零大小类型,ZST)。编译器可以优化这个类型的使用,将其视为一个全局的静态常量。因此,&Parent
是一个指向静态内存的引用,具有 'static
生命周期。也就是说,&Parent
的地址是固定的,它可以安全地被返回。
2. Parent { data: "Initial Data".to_string() }
是一个动态分配的值
struct Parent {
pub data: String,
}
String
是一个动态分配的类型,存储在堆上。当你写:
Parent { data: "Initial Data".to_string() }
Rust 会在堆上分配一段内存来存储字符串 "Initial Data"
,并在栈上存储 Parent
结构体实例,里面包含堆分配的 String
的元信息(如指针、长度、容量)。这是一个临时值,它的生命周期只持续到当前作用域(deref
函数体)结束。一旦 deref
返回,这个临时值会被释放,从而导致潜在的悬垂引用。
如何修复这个问题?
如果你希望返回一个有效的引用,可以通过以下几种方法:
方法 1:使用 static
定义一个全局静态值
将 Parent { data: ... }
定义为一个静态变量,并返回对它的引用:
static INSTANCE: Parent = Parent {
data: "Initial Data".to_string(),
};
fn deref(&self) -> &Self::Target {
&INSTANCE
}
- 优点:静态值的生命周期是
'static
,可以安全返回引用。 - 缺点:静态值是固定的,无法动态变化。
方法 2:将值存储在结构体中
将 Parent
的实例存储在 Child
的字段中,返回其引用:
struct Child {
inner: Parent,
}
impl Deref for Child {
type Target = Parent;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
- 优点:引用的生命周期和
Child
的实例绑定,符合生命周期规则。 - 缺点:每个
Child
实例需要持有一个Parent
实例。
方法 3:使用智能指针(Box
或 Rc
)
将 Parent
存储在堆上,并通过智能指针管理它的生命周期:
struct Child {
inner: Box<Parent>,
}
impl Deref for Child {
type Target = Parent;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
- 优点:灵活,支持动态分配。
- 缺点:引入了一些额外的运行时开销。
总结
- 为什么
&Parent
是静态值:- 因为
Parent
是一个零大小类型(ZST),它可以被优化为静态全局常量,生命周期是'static
。
- 因为
- 为什么
&Parent { data: ... }
不是静态值:- 因为
Parent { data: ... }
是一个临时值,它的生命周期只存在于当前作用域中,无法返回指向它的引用。
- 因为
- 如何解决:
- 使用静态值(
static
)、将值存储在结构体中,或者使用智能指针管理其生命周期。
- 使用静态值(
Rust 的生命周期规则严格保证了引用的安全性,这也是它在内存安全上的核心优势!