特殊大小的类型

大多数情况下,我们期望类型具有静态已知且正的大小。但这在 Rust 中并非总是如此。

动态大小的类型 (DSTs)

Rust 支持动态大小的类型 (DSTs):这类类型没有静态已知的大小或对齐方式。表面上看,这有点令人费解:Rust 必须知道某个事物的大小和对齐方式才能正确地处理它!在这方面,DSTs 不是普通类型。由于它们缺乏静态已知的大小,这些类型只能存在于指针之后。因此,指向 DST 的任何指针都会变成一个指针,它由指针和“补全”它们的信息组成(详见下文)。

语言暴露了两种主要的 DST

  • Trait 对象: dyn MyTrait
  • 切片: [T], str, 等等

Trait 对象表示实现其指定 Trait 的某种类型。原始的确切类型被擦除,取而代之的是通过一个包含使用该类型所需所有信息的虚表(vtable)进行运行时反射。补全 Trait 对象指针的信息是虚表指针。被指向对象的运行时大小可以从虚表中动态请求。

切片仅仅是对某个连续存储区域(通常是数组或 Vec)的一个视图。补全切片指针的信息就是它指向的元素数量。被指向对象的运行时大小就是元素的静态已知大小乘以元素的数量。

结构体实际上可以直接在其最后一个字段中存储单个 DST,但这也会使其成为 DST

#![allow(unused)]
fn main() {
// Can't be stored on the stack directly
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;

    // prints: "17 [0, 0, 0, 0, 0, 0, 0, 0]"
    println!("{} {:?}", dynamic.info, &dynamic.data);
}

(是的,目前自定义 DST 在很大程度上是一个半成品功能。)

零大小类型 (ZSTs)

Rust 还允许指定不占用空间的类型

#![allow(unused)]
fn main() {
struct Nothing; // No fields = no size

// All fields have no size = no size
struct LotsOfNothing {
    foo: Nothing,
    qux: (),      // empty tuple has no size
    baz: [u8; 0], // empty array has no size
}
}

就其本身而言,零大小类型 (ZSTs) 由于显而易见的原因相当无用。然而,正如 Rust 中许多有趣的布局选择一样,它们的潜力在泛型上下文中得以实现:Rust 在很大程度上理解,任何产生或存储 ZST 的操作都可以简化为无操作(no-op)。首先,存储它根本就没有意义——它不占用任何空间。此外,那种类型只有一个值,所以任何加载它的操作都可以凭空生成它——这同样是一个无操作,因为它不占用任何空间。

最极端的例子之一是集合(Sets)和映射(Maps)。给定一个 Map<Key, Value>,通常会将 Set<Key> 实现为 Map<Key, UselessJunk> 的一个薄层包装。在许多语言中,这需要为 UselessJunk 分配空间,并进行存储和加载 UselessJunk 的工作,结果却将其丢弃。证明这是不必要的对于编译器来说将是一个困难的分析。

然而在 Rust 中,我们可以直接说 Set<Key> = Map<Key, ()>。现在 Rust 静态地知道每次加载和存储都是无用的,并且没有任何分配具有大小。结果是,单态化(monomorphized)的代码基本上是一个定制的 HashSet 实现,没有任何 HashMap 为了支持值而必须承担的开销。

安全代码无需担心 ZSTs,但不安全代码必须注意无大小类型带来的后果。特别是,指针偏移是无操作(no-op),并且分配器通常要求非零大小

请注意,与所有其他引用一样,对 ZSTs 的引用(包括空切片)必须是非空且对齐良好的。解引用一个指向 ZST 的空指针或未对齐指针是未定义行为,就像对任何其他类型一样。

空类型

Rust 还允许声明甚至无法实例化的类型。这些类型只能在类型层面讨论,而不能在值层面讨论。空类型可以通过指定一个没有变体的枚举来声明

#![allow(unused)]
fn main() {
enum Void {} // No variants = EMPTY
}

空类型比 ZSTs 甚至更为边缘。空类型的主要用例是类型层面的不可达性。例如,假设一个 API 通常需要返回一个 Result,但某个特定情况实际上是不会失败的。通过返回 Result<T, Void>,实际上可以在类型层面传达这一点。API 的消费者可以自信地解开这样的 Result,因为他们知道这个值在静态上不可能Err,因为这需要提供一个 Void 类型的值。

原则上,Rust 可以基于这一事实进行一些有趣的分析和优化。例如,Result<T, Void> 仅表示为 T,因为 Err 情况实际上并不存在(严格来说,这只是一种不保证的优化,因此例如将一个强制转换(transmute)为另一个仍然是未定义行为)。

以下代码也能编译

#![allow(unused)]
fn main() {
enum Void {}

let res: Result<u32, Void> = Ok(0);

// Err doesn't exist anymore, so Ok is actually irrefutable.
let Ok(num) = res;
}

关于空类型的最后一个微妙细节是,指向它们的裸指针实际上是有效的构造,但解引用它们是未定义行为,因为这没有意义。

我们不建议使用 *const Void 来模拟 C 语言的 void* 类型。许多人开始这样做,但很快就遇到了麻烦,因为 Rust 实际上没有任何安全防护措施来阻止使用不安全代码实例化空类型,如果这样做,就是未定义行为。这尤其成问题,因为开发者习惯于将裸指针转换为引用,而构造 &Void 也是未定义行为。

*const ()(或等效类型)对于 void* 来说工作得相当好,并且可以毫无安全问题地转换为引用。它仍然不能阻止您尝试读取或写入值,但至少它编译为无操作(no-op),而不是未定义行为。

外部类型 (Extern Types)

存在一个已接受的 RFC,旨在添加具有未知大小的适当类型,称为外部类型(extern types),这将允许 Rust 开发者更精确地建模 C 语言的 void* 和其他“已声明但从未定义”的类型。然而,截至 Rust 2018 版本,该特性由于 size_of_val::<MyExternType>() 应如何行为而陷入僵局