历史。 早期的 Rust 要求所有类型必须具有静态已知大小,以保证堆栈分配和高效的值语义。当动态大小类型 (DSTs) 如切片 [T] 和特征对象 dyn Trait 被引入以支持灵活的数据结构时,语言需要一种机制来区分大小类型和潜在的未指定大小的泛型参数,而不打破现有代码。采用了 ?Sized 语法作为一种 "放宽" 约束,使得泛型可以显式选择退出默认的 Sized 要求,同时保留大多数涉及未指定数据的用例的良好默认。
问题。 隐式的 T: **Sized** 约束造成了根本的张力:它允许值操作和编译时内存计算,但阻止函数直接接受 dyn Trait 或切片类型,而不使用间接引用。这一限制迫使开发者即使在希望拥有所有权语义时也必须使用 Box 或引用,复杂化了旨在支持静态和动态多态性的 API。如果没有 ?Sized,泛型代码无法同时抽象出具体类型和运行时多态对象,从而导致强制堆分配或为大小和未大小变体重复接口。
解决方案。 编译器通过强制要求由 ?Sized 限定的类型只能通过胖指针访问来解决这个问题——胖指针是包含数据指针和运行时元数据(切片的长度,特征对象的虚表)的复合值。当一个泛型指定 T: **?Sized** 时,编译器禁止执行需要已知大小的操作,如 std::mem::size_of::<T>() 或按照值移动值,以确保所有内存布局在编译时仍然可计算。该设计允许零成本抽象,其中大小类型使用薄指针,而未指定大小类型使用胖指针,类型系统透明地处理这种区分。
一个系统监控库需要记录可能是小的、堆栈分配的错误代码或大的、动态格式化的错误消息(实现 dyn **Display**)。最初的 API 设计使用 fn log<T: **Display**>(error: T) 拒绝特征对象,因为隐式的 Sized 约束阻止了 dyn Display 满足该约束,给动态错误处理带来了显著的使用障碍。
考虑的第一个方法是强制对所有错误类型使用 Box<dyn **Display**,甚至将简单的 u32 错误代码转换为堆分配。优点:统一了 API 表面并允许动态错误的所有权,而无需复杂的泛型。缺点:引入了不适合嵌入式目标的分配器依赖,并给处理简单、静态错误的热路径增加了可测的延迟。
第二个选项涉及维护两个独立的日志记录方法:一个用于泛型 T: **Display** 的大小类型,另一个专门用于 &dyn **Display**。优点:避免了对大小类型的堆分配,并正确支持复杂错误的动态调度。缺点:需要大量代码重复,复杂化了公共 API 文档,并迫使调用者根据对类型大小的事先了解选择正确的方法。
团队选择了第三种方法,使用 fn log<T: **?Sized** + **Display**>(error: &T),接受对大小和未大小类型的引用。选择这个解决方案是因为它保持了单一一致的 API 入口点,支持 no-std 环境,避免了强制装箱,并且与双方法相比没有运行时开销。泛型实现编译为与原始单态版本相同的机器代码,同时通过虚表调度正确处理了特征对象。
最终的 crate 成功在微控制器和服务器上部署,处理数百万个异构错误事件而没有分配开销。统一接口允许开发者无缝传递 &ConcreteError 和 &dyn Error,展示了 ?Sized 如何在不同的部署目标之间实现真正的零成本多态。
为什么函数不能返回类型为 T 的值,其中 T: **?Sized**?
返回值的函数必须将这些值放置在寄存器或堆栈上,需要已知大小以生成正确的调用约定代码并保留适当的堆栈空间。由于 ?Sized 类型如 [i32] 或 dyn **Debug** 具有运行时确定的大小,编译器无法生成 ABI 所需的固定大小返回指令序列。只有指针类型(Box<T>,&T)具有静态已知的大小(usize 或胖指针宽度),使它们成为未大小数据的合法返回类型,从根本上限制了 ?Sized 泛型为“视图”类型,而不是可以按值移动的“值”类型。
**?Sized** 如何与关于引用的特征实现的一致性规则进行交互?
当为 &T 实现特征时,T: **?Sized** 的实现会自动适用于胖指针(如 &[i32] 或 &dyn Trait),因为这些仅仅是对 ?Sized 类型的引用。候选人常常忽视 impl Trait for &T where T: **?Sized** 涵盖了薄指针和胖指针,而 impl Trait for T where T: **Sized** 并没有。这一区别对于定义适用于大小数据和特征对象的通用实现至关重要,确保了类型层次结构的一致性而不重叠实现,从而违反了 Rust 的孤儿规则。
除了所有权语义,Box<dyn Trait> 与 &dyn Trait 的内存表示有什么区别?
虽然两者都使用胖指针(指针 + 虚表),**Box<dyn Trait>** 拥有分配并存储虚表指针,专门用于释放,而 **&dyn Trait** 只是观察数据。重要的是,Box<T> 其中 T: **?Sized** 需要分配器处理带有存储在虚表中的大小的动态大小释放,而引用则不承担此责任。初学者常常忽视 Box 使得未大小类型的堆分配成为可能,而这些类型不能存在于堆栈上,而引用仅仅是借用现有的内存,使得 Box 对于从函数中返回拥有的未大小数据至关重要。