非正常大小的类型

大多数的时候,我们期望类型在编译时能够有一个静态已知的非零大小,但这并不总是 Rust 的常态。

Dynamically Sized Types (DSTs)

Rust 支持动态大小的类型(DST):这些类型没有静态(编译时)已知的大小或者布局。从表面上看这有点离谱:Rust 必须知道一个东西的大小和布局,才能正确地进行处理。从这个角度上看,DST 不是一个普通的类型,因为它们没有编译时静态可知的大小,它们只能存在于一个指针之后。任何指向 DST 的指针都会变成一个包含了完善 DST 类型信息的胖指针(详情见下方)。

Rust 暴露了两种主要的 DST 类型:

  • trait objects:dyn MyTrait
  • slices:[T]str及其他

Trait 对象代表某种类型,实现了它所指定的 Trait。确切的原始类型被删除,以利于运行时的反射,其中包含使用该类型的所有必要信息的 vtable。补全 Trait 对象指针所需的信息是 vtable 指针,被指向的对象的运行时的大小可以从 vtable 中动态地获取。

一个 slice 只是一些只读的连续存储——通常是一个数组或Vec。补全一个 slice 指针所需的信息只是它所指向的元素的数量,指针的运行时大小只是静态已知元素的大小乘以元素的数量。

结构实际上可以直接存储一个 DST 作为其最后一个字段,但这也会使它们自身成为一个 DST:


#![allow(unused)]
fn main() {
// 不能直接存储在栈上
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;

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

(是的,自定义 DST 目前仅仅是一个基本半成品的功能。)

零大小类型 (ZSTs)

Rust 也允许类型指定他们不占空间:


#![allow(unused)]
fn main() {
struct Nothing; // 无字段意味着没有大小

// 所有字段都无大小意味着整个结构体无大小
struct LotsOfNothing {
    foo: Nothing,
    qux: (),      // 空元组无大小
    baz: [u8; 0], // 空数组无大小
}
}

就其本身而言,零尺寸类型(ZSTs)由于显而易见的原因是相当无用的。然而,就像 Rust 中许多奇怪的布局选择一样,它们的潜力在通用语境中得以实现。在 Rust 中,任何产生或存储 ZST 的操作都可以被简化为无操作(no-op)。首先,存储它甚至没有意义——它不占用任何空间。另外,这种类型的值只有一个,所以任何加载它的操作都可以直接凭空产生它——这也是一个无操作(no-op),因为它不占用任何空间。

这方面最极端的例子之一是 Set 和 Map。给定一个Map<Key, Value>,通常可以实现一个Set<Key>,作为Map<Key, UselessJunk>的一个薄封装。在许多语言中,这将需要为无用的封装分配空间,并进行存储和加载无用封装的工作,然后将其丢弃。对于编译器来说,证明这一点是不必要的,是一个困难的分析。

然而在 Rust 中,我们可以直接说Set<Key> = Map<Key, ()>。现在 Rust 静态地知道每个加载和存储都是无用的,而且没有分配有任何大小。其结果是,单例化的代码基本上是 HashSet 的自定义实现,而没有 HashMap 要支持值所带来的开销。

安全的代码不需要担心 ZST,但是不安全的代码必须小心没有大小的类型的后果。特别是,指针偏移是无操作的,而分配器通常需要一个非零的大小

请注意,对 ZST 的引用(包括空片),就像所有其他的引用一样,必须是非空的,并且适当地对齐。解引用 ZST 的空指针或未对齐指针是未定义的行为,就像其他类型的引用一样。

空类型

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


#![allow(unused)]
fn main() {
enum Void {} // 没有变体的类型 = 空类型
}

空类型甚至比 ZST 更加边缘化。空类型的主要作用是为了让某个类型不可达。例如,假设一个 API 需要在一般情况下返回一个结果,但一个特定的情况实际上是不可能的。实际上可以通过返回一个Result<T, Void>来在类型级别上传达这个信息。API 的消费者可以放心地 unwrap 这样一个结果,因为他们知道这个值在本质上不可能是Err,因为这需要提供一个Void类型的值。

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

比如以下的例子,曾经是可以编译成功的:


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

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

// 不存在 Err 的情况,所以 Ok 实际上永远都能匹配成功
let Ok(num) = res;
}

但现在,已经不让这么玩儿了。

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

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

*const ()(或等价物)对void*来说效果相当好,可以做成引用而没有任何安全问题。它仍然不能阻止你试图读取或写入数值,但至少它可以编译成一个 no-op 而不是 UB。

外部类型

有一个已被接受的 RFC 来增加具有未知大小的适当类型,称为 extern 类型,这将让 Rust 开发人员更准确地模拟像 C 的void*和其他“声明但从未定义”的类型。然而,截至 Rust 2018,该功能在size_of_val::<MyExternType>()应该如何表现方面遇到了一些问题