深入理解 Rust 动态大小类型(DST):从内存布局到 Sized Trait
在学习 Rust 时,我们经常接触 String、str、Vec<T>、[T]、&str 等类型。虽然这些类型在使用方式上存在相似之处,但它们在类型系统和内存布局方面却有着本质区别。
例如,下面的代码完全合法:
let a: [i32; 3] = [1, 2, 3];
let s: String = String::from("hello");
但是,下面的代码却无法通过编译:
let a: [i32] = [1, 2, 3]; // 编译错误
let s: str = "hello"; // 编译错误
为什么 [i32; 3] 可以作为局部变量,而 [i32] 不可以?为什么 String 可以按值存储,而 str 通常只能通过引用使用?
要理解这些问题,我们需要学习 Rust 类型系统中的一个重要概念:动态大小类型(Dynamically Sized Type,DST)。
本文将从编译器如何确定数据的内存布局出发,逐步介绍 DST 的定义、典型类型、内存表示,以及它与 Sized Trait、胖指针和 Deref 之间的关系。
一、为什么编译器需要知道类型的大小?
在理解动态大小类型之前,我们首先需要思考一个基础问题:
当 Rust 编译器为一个局部变量分配存储空间时,它需要知道什么?
考虑下面的代码:
fn main() {
let a: i32 = 100;
let b: [i32; 3] = [10, 20, 30];
}
在一种典型的栈帧实现中,编译器需要决定为局部变量预留多少存储空间。
对于变量 a,其类型为 i32,占用 4 字节。
对于变量 b,其类型为 [i32; 3],包含三个 i32 元素,每个元素占用 4 字节,因此:
编译器在编译代码时,就能够确定这两个类型的大小。
可以将它们的逻辑存储形式理解为:
a: i32
┌──────────────┐
│ 100 │ 4 Bytes
└──────────────┘
b: [i32; 3]
┌──────┬──────┬──────┐
│ 10 │ 20 │ 30 │
└──────┴──────┴──────┘
4 B 4 B 4 B
需要注意,实际编译后的变量可能保存在寄存器中,也可能被优化掉。这里讨论的是类型的内存布局,而不是某次编译必然采用的存储位置。
从类型系统的角度看,i32 和 [i32; 3] 有一个共同特点:
仅根据类型本身,就可以在编译时确定它们的大小。
Rust 将具有这种性质的类型称为静态大小类型(Sized Type)。
1.1 Sized Trait
Rust 使用一个特殊的标记 Trait:Sized,表示某个类型的大小在编译时已知。
例如:
i32
u64
bool
[i32; 3]
[i32; 100]
String
Vec<i32>
它们都属于 Sized 类型。
可以通过 std::mem::size_of 获取这些类型的大小:
use std::mem::size_of;
fn main() {
println!("{}", size_of::<i32>()); // 4
println!("{}", size_of::<[i32; 3]>()); // 12
println!("{}", size_of::<[i32; 5]>()); // 20
}
Sized 由编译器自动实现,程序员不需要也不能像普通 Trait 那样手动实现它。
但是,并非所有 Rust 类型的大小都能够在编译时确定。
这就引出了动态大小类型。
二、什么是动态大小类型(DST)?
Rust 官方对动态大小类型的定义可以概括为:
动态大小类型是指其具体值的大小只能在运行时确定,而不能仅根据类型本身在编译时确定的类型。
动态大小类型简称 DST,有时也被称为 Unsized Type。
为了准确理解这个定义,需要特别注意:
DST 的关键不在于数据是否可以动态增长,而在于类型本身是否包含足够的信息来确定值的大小。
我们以数组和切片为例。
2.1 固定长度数组:[T; N]
考虑:
let a: [i32; 3] = [10, 20, 30];
[i32; 3] 是一种固定长度数组类型,其类型包含两部分信息:
- 元素类型:
i32 - 元素数量:
3
因此,编译器可以根据类型计算出数组的大小:
对于 [i32; 3]:
值得注意的是:
[i32; 3]
[i32; 5]
是两个不同的类型。
数组长度 N 是 Rust 数组类型的组成部分。
2.2 动态大小切片:[T]
现在考虑:
[i32]
它与 [i32; 3] 的区别在于:[i32] 的类型中不包含元素数量。
[i32] 表示一个由连续 i32 元素组成的切片,但这个类型本身并没有告诉编译器具体包含多少个元素。
例如,不同的切片值可能包含三个、五个或者一千个元素,但它们都可以具有 [i32] 这个类型。
因此:
其中,(N) 是具体切片值的元素数量。
仅根据 [i32] 这个类型,编译器无法确定 (N),自然也无法确定其大小。
所以:
[i32] 是一个动态大小类型,而 [i32; 3] 是静态大小类型。
这也解释了为什么下面的代码不合法:
let a: [i32] = [10, 20, 30];
Rust 不允许将这种 DST 直接作为普通的按值局部变量存储。
那么,如果切片本身无法按值存储,我们应该如何使用它?
答案是:通过引用或其他适当的指针类型。
三、Rust 如何使用动态大小类型?
虽然 DST 的大小无法仅由类型在编译时确定,但这并不意味着 Rust 无法操作 DST。
关键在于:
当通过指针间接访问 DST 时,Rust 可以在指针中携带额外的元数据,用来描述目标值的大小或类型信息。
3.1 通过引用使用切片
例如:
fn main() {
let a = [10, 20, 30, 40, 50];
let s: &[i32] = &a[1..4];
println!("{:?}", s);
}
输出:
[20, 30, 40]
这里存在两个不同的类型:
a: [i32; 5]
s: &[i32]
其中:
[i32; 5]是静态大小类型。[i32]是动态大小类型。&[i32]是引用类型,本身具有编译时确定的大小。
最后一点非常重要:
被引用的目标类型可以是 DST,但引用本身依然是 Sized 类型。
那么 &[i32] 如何知道它所引用的数据包含三个元素呢?
这就涉及胖指针。
3.2 胖指针(Fat Pointer)
普通引用通常只需要保存目标对象的内存地址。
例如:
let x: i32 = 100;
let r: &i32 = &x;
&i32 的目标类型大小固定,因此只需要保存数据地址。
这种指针通常称为瘦指针(Thin Pointer)。
r: &i32
┌───────────────┐
│ ptr ──────────┼────────┐
└───────────────┘ │
▼
┌─────────┐
│ 100 │
└─────────┘
但是,对于:
let a = [10, 20, 30];
let s: &[i32] = &a;
如果 s 只包含一个地址,程序就无法仅从该地址知道切片包含多少个元素。
因此,&[i32] 通常需要保存两部分信息:
- 数据指针
ptr:指向切片第一个元素。 - 长度
len:表示切片包含多少个元素。
其概念性布局如下:
s: &[i32]
┌─────────────────┐
│ ptr ────────────┼───────────┐
├─────────────────┤ │
│ len = 3 │ ▼
└─────────────────┘ ┌──────┬──────┬──────┐
│ 10 │ 20 │ 30 │
└──────┴──────┴──────┘
这种同时携带数据地址与元数据的指针,通常称为胖指针(Fat Pointer)或宽指针(Wide Pointer)。
在常见的 64 位平台上:
&i32 8 Bytes
&[i32] 16 Bytes
虽然它们的大小不同,但二者都是 Sized 类型,因为引用自身的表示大小在编译时可以确定。
这里需要区分两个完全不同的概念:
- DST 的大小:被引用的数据实际占用多少字节。
- 胖指针的大小:保存数据地址与元数据需要多少字节。
胖指针的大小通常固定,而它所指向的 DST 大小可以随具体值改变。
3.3 使用 size_of_val 获取 DST 的实际大小
Rust 提供了:
std::mem::size_of_val
它可以获取一个具体值的大小,包括 DST。
例如:
use std::mem::{size_of, size_of_val};
fn main() {
let a = [10, 20, 30, 40, 50];
let s: &[i32] = &a[1..4];
println!("{}", size_of::<[i32; 5]>()); // 20
println!("{}", size_of_val(s)); // 12
println!("{}", size_of_val(&s)); // 16(典型64位平台)
}
重点分析:
size_of_val(s)
由于 s 的类型是 &[i32],因此传给 size_of_val 的是切片引用。
函数计算的是其所指向的 [i32] 值的大小。
该切片有三个元素,所以:
而:
size_of_val(&s)
测量的是 s 这个胖指针值本身的大小。
因此,两者并不矛盾。
此外:
size_of::<[i32]>()
不能通过编译,因为 size_of::<T>() 要求类型 T 实现 Sized,而 [i32] 不满足这个要求。
四、为什么 str 是 DST,而 String 不是?
理解了 [T],我们就能够解释 Rust 字符串类型的设计。
4.1 str 表示 UTF-8 字符串内容
考虑:
let a: &str = "hello";
let b: &str = "你好";
虽然 a 和 b 都是 &str,但它们指向的字符串内容大小不同。
"hello" 5 Bytes
"你好" 6 Bytes
因为 UTF-8 编码中,常见汉字通常占用三个字节。
str 表示一段有效的 UTF-8 字节序列,但类型本身不包含具体的字节长度。
因此,str 是 DST。
而 &str 是对 str 的共享引用。
和数组切片的引用一样,&str 通常是胖指针,包含:
&str
├── ptr:UTF-8 字节序列的起始地址
└── len:字符串的字节长度
这里的 len 是字节数,并不是 Unicode 字符数量。
例如:
fn main() {
let s: &str = "你好";
println!("{}", s.len()); // 6
println!("{}", s.chars().count()); // 2
}
4.2 为什么 String 是 Sized 类型?
现在考虑:
let a = String::from("hello");
let b = String::from("这是一个更长的字符串");
两个字符串的字节长度不同,但是变量 a 和 b 的类型都是 String。
String 本身保存的是管理字符串所需的元数据,而不是把所有字符直接内嵌在 String 值里。
概念上:
String
├── ptr 指向堆内存
├── len 已使用的字节长度
└── capacity 已分配的容量
因此,无论实际字符串有多长,String 对象自身的大小都是固定的。
在常见的 64 位平台上:
std::mem::size_of::<String>()
通常返回 24 字节。
字符串数据则存储在由 String 管理的堆分配中。
这解释了一个看似矛盾的事实:
String 可以在运行时动态增长,但 String 仍然是 Sized 类型。
因为 Sized 描述的是 String 对象本身的大小,而不是它所管理的堆内存大小。
对比来看:
| 类型 | 是否 Sized | 主要原因 |
|---|---|---|
str |
否 | UTF-8 内容的字节长度不包含在类型中 |
&str |
是 | 胖指针自身大小固定 |
String |
是 | 管理字符串的元数据大小固定 |
&String |
是 | 指向 String 对象的引用大小固定 |
所以,不能把“长度可变的类型”直接等同于 DST。
五、Trait Object:另一种重要的 DST
除了 [T] 和 str,Rust 还有一种常见的动态大小类型:Trait Object。
例如:
dyn std::fmt::Display
为什么 dyn Display 是 DST?
首先来看:
use std::fmt::Display;
fn main() {
let a: i32 = 100;
let b = String::from("hello");
let x: &dyn Display = &a;
let y: &dyn Display = &b;
println!("{x}");
println!("{y}");
}
这里,i32 与 String 都实现了 Display Trait。
但是,它们的大小不同:
i32 4 Bytes
String 通常为24 Bytes(64位平台)
当我们使用:
dyn Display
时,具体实现类型的信息被抽象掉了。
编译器仅凭 dyn Display,无法确定底层对象究竟是 i32、String,还是其他实现了 Display 的类型。
因此,dyn Display 属于 DST。
5.1 Trait Object 的胖指针
对于:
let x: &dyn Display = &a;
x 通常包含两部分信息:
&dyn Display
┌────────────────────┐
│ data pointer │ → 实际对象
├────────────────────┤
│ vtable pointer │ → 虚表
└────────────────────┘
这里与 &[T] 存在一个重要区别:
&[T]的元数据是元素数量。&str的元数据是字节长度。&dyn Trait的元数据通常是虚表指针。
虚表(Virtual Method Table,vtable)包含动态分发所需要的方法信息,以及底层具体类型的大小、对齐等相关信息。
因此,在运行时,Rust 可以通过 Trait Object 访问具体对象,并执行相应方法。
这也说明:胖指针携带的元数据不一定是长度,也可能是描述具体类型行为与布局的信息。
5.2 DST 与动态分发的区别
需要注意,动态大小类型和动态分发(Dynamic Dispatch)不是同一个概念。
- DST 关注的是类型的大小能否在编译时确定。
- 动态分发关注的是具体调用哪个方法实现,是否需要在运行时决定。
dyn Trait 同时涉及这两方面,但它们属于不同的语言问题。
六、Sized 与 ?Sized:Rust 如何约束动态大小类型?
DST 还直接影响 Rust 的泛型系统。
6.1 泛型参数默认要求 Sized
考虑:
fn print_value<T>(value: T) {
println!("hello");
}
在 Rust 中,普通泛型类型参数 T 默认具有一个隐含的约束:
T: Sized
也就是说,编译器通常要求 T 的大小在编译时已知。
这一设计很容易理解:
当函数按值接收参数:
value: T
时,编译器需要知道如何传递、存储和管理这个值。
因此,默认的 Sized 约束能够简化泛型代码的类型布局和调用约定。
6.2 使用 ?Sized 放宽约束
但如果我们希望泛型函数同时接受 Sized 和 DST 呢?
Rust 提供了:
T: ?Sized
这里的 ?Sized 并不表示“T 必须是动态大小类型”。
它真正的含义是:
取消泛型参数默认的 Sized 限制,使 T 可以是 Sized,也可以是 DST。
例如:
use std::mem::size_of_val;
fn print_size<T: ?Sized>(value: &T) {
println!("size = {}", size_of_val(value));
}
fn main() {
let a = [10, 20, 30];
let s: &[i32] = &a;
print_size(s);
let text: &str = "hello";
print_size(text);
}
输出:
size = 12
size = 5
分析:
第一次调用中,泛型参数可以推断为:
T = [i32]
第二次调用中:
T = str
这两个类型都属于 DST。
因为函数使用了:
T: ?Sized
所以它们可以作为泛型类型参数。
但是,函数参数仍然声明为:
value: &T
而不是:
value: T
原因在于,即使放宽了 Sized 约束,也不意味着 DST 可以直接按值传递。
6.3 为什么还需要 &T?
考虑:
fn print_size<T: ?Sized>(value: T) {
// ...
}
这仍然无法通过编译,因为普通函数参数必须具有编译时已知的大小。
所以,?Sized 放宽的是泛型参数允许表示哪些类型,而不是取消了 Rust 对按值参数的布局要求。
正确的写法是:
fn print_size<T: ?Sized>(value: &T) {
// ...
}
这里的 &T 是一个具有确定大小的引用类型。
即使 T 是 DST,函数仍然可以通过引用间接访问目标值。
6.4 为什么 Deref Trait 需要 ?Sized?
回到 Rust 的 Deref Trait。
其核心接口可以简化表示为:
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
其中:
type Target: ?Sized;
意味着解引用的目标类型不一定要实现 Sized。
这十分重要。
因为 String 实现了:
impl std::ops::Deref for String {
type Target = str;
fn deref(&self) -> &str {
self.as_str()
}
}
而 str 恰好就是 DST。
如果 Deref::Target 必须实现 Sized,String 就无法将 str 作为解引用目标。
这正是 DST、?Sized 和 Deref 之间的关键联系。
七、Rust 允许哪些形式的 DST?
到目前为止,我们已经接触了三种常见的 DST:
str
[T]
dyn Trait
除此之外,Rust 还允许结构体的最后一个字段是 DST,使整个结构体成为 DST。
例如:
struct Packet<T: ?Sized> {
id: u32,
payload: T,
}
如果:
T = [u8]
那么:
Packet<[u8]>
也属于 DST。
因为最后一个字段 payload 的大小无法仅从其类型确定。
可以通过下面的方式使用这种结构:
struct Packet<T: ?Sized> {
id: u32,
payload: T,
}
fn main() {
let packet = Packet {
id: 1,
payload: [10u8, 20, 30],
};
let view: &Packet<[u8]> = &packet;
println!("{}", view.id);
println!("{:?}", &view.payload);
}
这里 packet 的具体类型是:
Packet<[u8; 3]>
它是 Sized 类型。
而 view 引用的类型是:
Packet<[u8]>
它是 DST。
Rust 可以将前者的引用强制转换成后者的引用。
这说明 DST 不仅限于语言内置的 str、切片和 Trait Object,还可以出现在满足布局规则的复合类型中。
八、动态大小类型不等于动态内存分配
最后,我们需要澄清一个特别容易出现的误区:
动态大小类型是不是指在堆上动态分配的类型?
答案是否定的。
DST 和堆内存分配描述的是两个完全不同的维度。
DST 是类型系统中的概念,描述编译时能否确定值的大小。
堆分配则是一种运行时存储管理方式。
例如:
let a = [10, 20, 30, 40, 50];
let s: &[i32] = &a[1..4];
这里:
[i32]是 DST。s指向数组a中的一部分。a可以存储在栈上。- 创建
s不需要进行堆分配。
再看:
let s = String::from("hello");
这里:
String是 Sized 类型。- 字符串内容通常存储在堆上。
String对象自身的大小在编译时已知。
我们甚至可以将 DST 放在堆上,并通过 Box 管理:
fn main() {
let text: Box<str> =
String::from("hello").into_boxed_str();
let numbers: Box<[i32]> =
vec![10, 20, 30].into_boxed_slice();
println!("{text}");
println!("{numbers:?}");
}
这里:
Box<str>
Box<[i32]>
都是 Sized 的智能指针类型,而它们所管理的目标 str 和 [i32] 是 DST。
因此,不应该把 DST 简单理解成“堆上的动态数组”或者“运行时动态增长的对象”。
九、总结
理解 Rust 动态大小类型,最重要的是建立三个层次的认识。
第一,区分类型本身的大小与类型所管理的数据大小。
例如,String 管理的字符串内容可以增长,但 String 对象自身大小固定,所以它属于 Sized 类型。
第二,区分动态大小类型与指向动态大小类型的指针。
例如,[i32] 和 str 是 DST,但是 &[i32] 和 &str 是 Sized 类型。
Rust 可以通过胖指针中的元数据,在运行时获知被引用对象的具体大小。
第三,理解 Sized 与 ?Sized 的作用。
Sized 表示类型的大小在编译时已知。Rust 的泛型参数默认具有这一约束,而 ?Sized 允许我们在引用等适当的间接访问场景中使用 DST。
可以通过下面的表格统一理解:
| 类型 | Sized | 核心原因 |
|---|---|---|
i32 |
是 | 固定大小的整数类型 |
[i32; 3] |
是 | 数组长度是类型的一部分 |
[i32] |
否 | 切片长度不包含在类型中 |
str |
否 | UTF-8 内容的长度不包含在类型中 |
&str |
是 | 胖指针自身具有固定大小 |
&[i32] |
是 | 胖指针自身具有固定大小 |
String |
是 | 管理字符串的元数据大小固定 |
Vec<i32> |
是 | 管理动态数组的元数据大小固定 |
dyn Display |
否 | 具体实现类型的大小被擦除 |
&dyn Display |
是 | 指向 Trait Object 的胖指针 |
Box<str> |
是 | 拥有 DST 的智能指针 |
回到最初的问题:
为什么下面的代码不合法?
let s: str = "hello";
而下面的代码合法?
let s: &str = "hello";
现在我们知道:
str 是一个动态大小类型,不能直接作为普通的按值局部变量存储;而 &str 是具有固定大小的引用,它通过数据地址与长度元数据来描述一个具体的字符串切片。
最终,我们可以用一句话概括 DST 的本质:
Rust 的动态大小类型(DST)是指具体值的大小不能仅从其类型在编译时确定的类型。Rust 通过引用、智能指针以及相应的运行时元数据,在保证类型安全的前提下支持这类数据。
理解 DST,不仅能够帮助我们区分 String 和 str、数组和切片,也为进一步理解胖指针、Trait Object、动态分发、Deref、?Sized 以及 Rust 的内存布局奠定了基础。

浙公网安备 33010602011771号