深入理解 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 字节,因此:

\[\operatorname{size\_of}([i32;3])=3\times4=12\text{ Bytes} \]

编译器在编译代码时,就能够确定这两个类型的大小。

可以将它们的逻辑存储形式理解为:

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

因此,编译器可以根据类型计算出数组的大小:

\[\operatorname{size\_of}([T;N]) =N\times\operatorname{size\_of}(T) \]

对于 [i32; 3]:

\[3\times4=12\text{ Bytes} \]

值得注意的是:

[i32; 3]
[i32; 5]

是两个不同的类型。

数组长度 N 是 Rust 数组类型的组成部分。

2.2 动态大小切片:[T]

现在考虑:

[i32]

它与 [i32; 3] 的区别在于:[i32] 的类型中不包含元素数量。

[i32] 表示一个由连续 i32 元素组成的切片,但这个类型本身并没有告诉编译器具体包含多少个元素。

例如,不同的切片值可能包含三个、五个或者一千个元素,但它们都可以具有 [i32] 这个类型。

因此:

\[\operatorname{size}([i32])=N\times4 \]

其中,(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] 通常需要保存两部分信息:

  1. 数据指针 ptr:指向切片第一个元素。
  2. 长度 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] 值的大小。

该切片有三个元素,所以:

\[3\times4=12\text{ Bytes} \]

而:

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 的内存布局奠定了基础。


参考资料

  1. The Rust Reference — Dynamically Sized Types
  2. The Rust Reference — Type Layout
  3. The Rust Reference — Slice Types
  4. The Rust Programming Language — Dynamically Sized Types and the Sized Trait
  5. Rust Standard Library — Sized
  6. Rust Standard Library — size_of_val
posted @ 2026-10-29 15:01  ZhengQC  阅读(6)  评论(0)    收藏  举报